diff --git a/assets/css/_darkmode.scss b/assets/css/_darkmode.scss index aea3fde3f..a5dd13135 100644 --- a/assets/css/_darkmode.scss +++ b/assets/css/_darkmode.scss @@ -329,4 +329,23 @@ $tooltip-shadow: #333; filter:invert(1) hue-rotate(180deg); } + .prp-qualitytext { + color:white; /* needed to have enough contrast */ + } + .prp-quality0 { + background-color:#55585e; + } + .prp-quality1 { + background-color:#971602; + } + .prp-quality2 { + background-color:#40408c; + } + .prp-quality3 { + background-color:#5C5C00; + } + .prp-quality4 { + background-color:#00662e; + } + } diff --git a/assets/css/application.scss b/assets/css/application.scss index fe2546220..e1dfda63a 100644 --- a/assets/css/application.scss +++ b/assets/css/application.scss @@ -741,7 +741,29 @@ a.help-icon { clear: both; } + +.prp-qualitytext { + border-radius: 3px; + padding: 0 2px; +} + +.prp-quality0 { + background-color:#ddd; +} +.prp-quality1 { + background-color:#ffabab; +} +.prp-quality2 { + background-color:#bbbbff; +} +.prp-quality3 { + background-color:#ffe867; +} +.prp-quality4 { + background-color:#90ff90; +} + .link-loading { pointer-events: none; font-style: italic; -} +} \ No newline at end of file diff --git a/assets/css/editcounter.scss b/assets/css/editcounter.scss index c0b68e609..f15700370 100644 --- a/assets/css/editcounter.scss +++ b/assets/css/editcounter.scss @@ -41,6 +41,10 @@ height: auto; width: auto; } + + div.chart-wrapper.qualitychangechart { + float:none; + } .top-project-edit-counts td { white-space: nowrap; diff --git a/i18n/en.json b/i18n/en.json index ed9ad522c..cb910e33d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -331,6 +331,8 @@ "priority": "Priority", "project": "Project", "projects": "Projects", + "proofreadpage-quality": "Page status", + "proofreadpage-qualitychanges": "Proportions of ProofreadPage quality changes", "prose": "Prose", "protect": "Protect", "proxy-check": "Proxy check", diff --git a/i18n/qqq.json b/i18n/qqq.json index 9a3a256c2..7d8769a47 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -351,6 +351,8 @@ "priority": "Label for the priority of a 'bug' in a wiki page that needs fixing.\n{{Identical|Priority}}", "project": "Header for the part of the interface that lets the user select which project they want to view statistics for.\n{{Identical|Project}}", "projects": "Label for a number of projects.\n{{Identical|Projects}}", + "proofreadpage-quality": "Label for the transcription status of a page, as recorded by Extension:ProofreadPage.", + "proofreadpage-qualitychanges": "Label for a chart of the proportions of the different quality changes.", "prose": "Term used to describe the text content of an article.", "protect": "Name of the MediaWiki log action 'protect', as in page protection.\n{{Identical|Protect}}", "proxy-check": "Label for link to the Proxy Check tool on Toolforge. The tool can tell you if the given IP address is a proxy or not.", diff --git a/public/build/app.7692d209.css b/public/build/app.ed7ba741.css similarity index 61% rename from public/build/app.7692d209.css rename to public/build/app.ed7ba741.css index 7f9ddb33b..894520d81 100644 --- a/public/build/app.7692d209.css +++ b/public/build/app.ed7ba741.css @@ -1 +1 @@ -@charset "UTF-8";.contributions-nav{margin-bottom:10px}.contributions-nav:last-child{margin-bottom:0}.contributions-limit--wrapper{-webkit-transform:translateY(-5px);-moz-transform:translateY(-5px);-ms-transform:translateY(-5px);-o-transform:translateY(-5px);transform:translateY(-5px)}.contributions-limit--wrapper label{font-weight:400}.contributions-loading{display:none;float:left}.contributions-container--loading{opacity:.3}.contributions-container--loading .contributions-nav{visibility:hidden}.contributions-container--loading .contributions-loading{display:block}html{height:100%}body{background:url(/build/images/gradient.bc9a6010.png);min-height:100%;position:relative}body:after{content:"";display:block;min-height:148px}body.rtl{direction:rtl}body.rtl .tool-links{float:right;padding-right:0!important}body.rtl .tool-links .nav{padding-right:0}body.rtl .tool-links .nav li{float:right}body.rtl .lang-group ul{left:0;right:auto;text-align:right}body.rtl .nav-buttons{float:left!important;margin-left:8px;margin-right:0}body.rtl .navbar-toggle{float:right!important}body.rtl .back-to-search{left:auto;right:0}body.rtl .back-to-search .glyphicon-chevron-left{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}@media (max-width:767px){body.rtl .tool-links li{float:none!important}}body.rtl .form-row:first-child>:first-child{border-top-left-radius:0;border-top-right-radius:4px}body.rtl .form-row:first-child>:last-child{border-top-left-radius:4px;border-top-right-radius:0}body.rtl .input-group-addon:first-child{border-left:0;border-right:1px solid #ccc}body.rtl .form-row:last-child>:first-child{border-bottom-left-radius:0;border-bottom-right-radius:4px}body.rtl .form-row:last-child>:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:0}body.rtl .input-group-addon:last-child{border-left:1px solid #ccc;border-right:0}body.rtl .form-label{text-align:right}body.rtl .form-label .tooltipcss{float:left!important}body.rtl .tooltip-body{margin-left:0;margin-right:28px;text-align:right}body.rtl .tooltip-body:after,body.rtl .tooltip-body:before{left:100%;right:auto}body.rtl .tooltip-body:before{border-color:transparent transparent transparent #dca}body.rtl .tooltip-body:after{border-color:transparent transparent transparent #fffaf0}body.rtl .typeahead{text-align:right}body.rtl .app-footer .footer-about{padding-right:15px}body.rtl .app-footer .footer-branding{float:left!important}body.rtl .table td,body.rtl .table th{text-align:right}@media (min-width:1200px){body.rtl .stat-list{float:right!important}}body.rtl .stat-list>.table td:first-child,body.rtl .stat-list>.table th:first-child{text-align:left}body.rtl .stat-list>.table td:last-child,body.rtl .stat-list>.table th:last-child{text-align:right}@media (max-width:767px){body.rtl .stat-list>.table td:first-child{text-align:right}}body.rtl .panel{text-align:right}body.rtl .toggle-table{float:right}body.rtl .toggle-table--chart{float:right;margin-left:0!important;margin-right:100px!important}body.rtl .chart-legend{margin-left:0;margin-right:15px}body.rtl .chart-legend .color-icon{margin-left:5px;margin-right:0}body.rtl .chart-wrapper{float:right;margin-left:50px;margin-right:0}body.rtl .xt-panel-description{float:left!important}body.rtl .dropdown-menu-right{left:0;right:auto;text-align:right}body.rtl .times-in-utc{float:right}body.rtl.pageinfo .legend-body{text-align:right}body.rtl.pageinfo .sort-entry--assessment,body.rtl.pageinfo .sort-entry--importance{text-align:center}body.rtl.pageinfo .date-range{left:30px;right:auto}body.rtl .rm-inline-margin{margin-left:-4px;margin-right:0}body.rtl .diff-pos:before{display:none}body.rtl .diff-pos:after{color:#006400;content:"+"}body.rtl .side-to-side>div{margin:0 0 15px 10px}.strong{font-weight:700}#wrapper{background:#fff;border-radius:5px;margin:15px}.site-notice{margin:0 15px}.navbar-top{border-top:0;min-height:51px;padding:0}.navbar-top li:last-child{padding-right:0}.tool-links{float:left;padding:0}.tool-links.in{position:relative;top:8px}.tool-links .nav{margin:0}.nav-buttons{left:1px;margin-right:8px;position:relative;top:8px}.nav-buttons li{padding:0 2px}.navbar-nav>li>a{height:50px;line-height:50px;padding:0 10px}.home-link{bottom:1px;padding:0 12px;position:relative}.home-link:after{background:#e7e7e7;bottom:0;content:"";display:inline-block;height:80%;left:100%;margin:auto;position:absolute;top:3px;width:1px}.home-link img{height:32px}.navbar-toggle{left:5px;margin:0;position:relative;top:9px}.lang-group .dropdown-menu{max-height:194px;overflow-y:scroll}.lang-group .btn{padding-left:30px}.lang-group svg{height:17px;left:8px;position:absolute;top:8px;width:17px}.login-btn .glyphicon-user{padding-right:2px;top:2px}.xt-page-title>small:before{content:"•";margin-right:10px}.tooltipcss{position:static;text-decoration:none}.tooltipcss .tooltip-body{background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:5px 5px 8px #ccc;color:#111;display:none;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;line-height:16px;margin-left:28px;max-width:350px;min-width:200px;padding:14px 20px;text-align:left;white-space:normal;z-index:10}.tooltip-body:after,.tooltip-body:before,.tooltipcss .tooltip-body{position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.tooltip-body:after,.tooltip-body:before{border-style:solid;color:transparent;content:"";display:block;height:0;right:100%;width:0}.tooltip-body:before{border-color:transparent #ddd transparent transparent;border-width:11px}.tooltip-body:after{border-color:transparent #fff transparent transparent;border-width:10px}.tooltipcss:hover .tooltip-body{display:block}.callout{border:0;left:-12px;position:absolute;top:30px;z-index:20}.form-label{min-width:15em;text-align:left;white-space:normal}.xt-heading-top{font-size:1.8em;margin-bottom:0;padding-bottom:0;position:relative}.xt-heading-subtitle{margin-top:3px}.back-to-search{font-size:16px;left:0;position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.back-to-search:hover{text-decoration:none}.date-range{font-size:70%;position:absolute;right:30px;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}@media (max-width:767px){.date-range{display:block;left:0;position:relative;top:0;transform:none}}.xt-toc{background:#fff;font-size:110%;margin-top:20px;padding:10px;width:100%;z-index:100}.xt-toc span:after{content:" • "}.xt-toc span:last-child:after{content:""}.xt-toc .bold{font-weight:700}.xt-toc.fixed{box-shadow:5px 5px 8px #ccc;left:50%;margin:0;position:fixed;top:0;-webkit-transform:translateX(-50%);-moz-transform:translateX(-50%);-ms-transform:translateX(-50%);-o-transform:translateX(-50%);transform:translateX(-50%);white-space:nowrap;width:auto}.table-sticky-header{position:relative}.table-sticky-header .sticky-heading{background:#fff;left:0;position:absolute;top:0}.xt-alert{margin:0;padding:10px;text-align:center}.xt-alert:not(:first-child){margin-top:10px}.xt-alert .close{opacity:.8;right:0;top:0}.xt-error-alert{margin-top:20px}.xt-error-alert:only-child{margin:0}.panel-primary>.panel-heading a{color:#fff}section>.panel-body{overflow-x:auto}.panel-body .alert:last-child,.panel-body .table:only-child{margin-bottom:0}.xt-panel-body table{clear:both;white-space:nowrap}.xt-panel-body .panel-body{width:100%}.xt-table>tbody>tr>td{padding-bottom:1px;padding-top:1px;vertical-align:top}.xt-table>tbody>tr>th{font-weight:400;padding:5px 2px;white-space:nowrap}.xt-show{display:none}.xt-hide,.xt-show{bottom:2px;cursor:pointer;position:relative}.xt-hide:hover,.xt-show:hover{text-decoration:underline}.xt-panel-description{font-size:80%;font-weight:400;line-height:normal;margin-left:8px}.inline-block{display:inline-block}.table .show-more-row td{border:0}.xt-pagination{margin:0}.panel-danger,.panel-default,.panel-primary{margin-bottom:0}.panel-default{margin-top:20px;text-align:left}.panel-default>.panel-heading{padding:5px 10px}.login{background-image:linear-gradient(transparent,transparent),url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTIiIGhlaWdodD0iMTMuODM3Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImUiPjxzdG9wIG9mZnNldD0iMCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1vcGFjaXR5PSIwIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImIiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNiNzRiYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzJkNTk5MCIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNjOWM5YzkiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9IjAiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjRkOWIxIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZGY5NzI1Ii8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeDE9IjMwLjkzNiIgeTE9IjI5LjU1MyIgeDI9IjMwLjkzNiIgeTI9IjM1LjgwMyIgaWQ9ImgiIHhsaW5rOmhyZWY9IiNjIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIvPjxsaW5lYXJHcmFkaWVudCB4MT0iMjAuNjYyIiB5MT0iMzUuODE4IiB4Mj0iMjIuNjI3IiB5Mj0iMzYuMjE4IiBpZD0iayIgeGxpbms6aHJlZj0iI2UiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0icm90YXRlKDEwLjQ5IDE3LjU2MSAzMi42MykiLz48bGluZWFyR3JhZGllbnQgeDE9IjIyLjY4NyIgeTE9IjM2LjM5IiB4Mj0iMjEuNDA4IiB5Mj0iMzUuNzQiIGlkPSJsIiB4bGluazpocmVmPSIjZSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLS45NzggLjIxIC4yMSAuOTc4IDU1LjExIC0zLjk0NSkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJmIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI4LjA5IiBjeT0iMjcuMjAzIiByPSIxMy41NjUiIGZ4PSIyOC4wOSIgZnk9IjI3LjIwMyIgaWQ9ImciIHhsaW5rOmhyZWY9IiNiIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjI5OCAwIDAgLjg4NSAtOC4zNTkgNC45NCkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJpIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI5LjM0NSIgY3k9IjE3LjA2NCIgcj0iOS4xNjIiIGZ4PSIyOS4zNDUiIGZ5PSIxNy4wNjQiIGlkPSJqIiB4bGluazpocmVmPSIjZCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjc4OCAwIDAgLjc4OCA2LjIyMSAzLjYxOCkiLz48L2RlZnM+PGcgY29sb3I9IiMwMDAiPjxwYXRoIGQ9Ik0zOS43NzUgMTkuMDA5YTguNjYyIDguNjYyIDAgMSAxLTE3LjMyNCAwIDguNjYyIDguNjYyIDAgMSAxIDE3LjMyNCAweiIgdHJhbnNmb3JtPSJtYXRyaXgoLjY5MyAwIDAgLjM3NCAtMTUuNTQ4IDMuNDgxKSIgZmlsbD0idXJsKCNmKSIgZmlsbC1ydWxlPSJldmVub2RkIiBvdmVyZmxvdz0idmlzaWJsZSIvPjxwYXRoIGQ9Ik00LjA0NiAxMi4zOThoNC4xMzdjMS4xNzIgMCAyLjMzMi0uNDMgMi43NTgtMS42NTUuNDA0LTEuMTYzLjA2OS0zLjM3OC0yLjU1MS01LjE3MUgzLjQ5NUMuODc1IDcuMjI3LjU0OCA5LjQ4OSAxLjE1MSAxMC44MTJjLjYxNCAxLjM0NyAxLjY1NSAxLjU4NiAyLjg5NiAxLjU4NnoiIGZpbGw9InVybCgjZykiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMjA0YTg3IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBzdHJva2Utd2lkdGg9Ii4zOSIvPjxwYXRoIGQ9Ik00LjMyMSA2LjE5M2MxLjI0MSAxLjEwMyAxLjc5MyA1LjEwMiAxLjc5MyA1LjEwMnMuNTUyLTMuOTk5IDEuNTE3LTUuMTcxbC0zLjMwOS4wNjl6IiBmaWxsPSJ1cmwoI2gpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTUuMjEgNi42MDdzLS44MzkuNjQ4LS43NjcgMS40MjhjLS43OTYtLjcwMi0uODE5LTIuMDQ4LS44MTktMi4wNDhsMS41ODYuNjJ6IiBmaWxsPSIjNzI5ZmNmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0ibTQuMDE4IDExLjk5MiA0LjA5Mi0uMDA5YzEuMDI5IDAgMi4wNDktLjM3NyAyLjQyMi0xLjQ1My4zNTUtMS4wMjItLjAzNy0yLjk2Ny0yLjMzOC00LjU0MmwtNC40OTUtLjA5NUMxLjM5OCA3LjM0Ni45NTIgOS4zMzQgMS40OTEgMTAuNTljLjUzOCAxLjI1NiAxLjMyNCAxLjM5MyAyLjUyNiAxLjQwMXoiIG9wYWNpdHk9Ii4yMTUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuMzkiLz48cGF0aCBkPSJNNi45NDEgNi42MDdzLjgzOS42NDguNzY3IDEuNDI4Yy43OTYtLjcwMi44MTktMi4wNDguODE5LTIuMDQ4bC0xLjU4Ni42MnoiIGZpbGw9IiM3MjlmY2YiIGZpbGwtcnVsZT0iZXZlbm9kZCIgb3ZlcmZsb3c9InZpc2libGUiLz48cGF0aCBkPSJNMzkuNzc1IDE5LjAwOWE4LjY2MiA4LjY2MiAwIDEgMS0xNy4zMjQgMCA4LjY2MiA4LjY2MiAwIDEgMSAxNy4zMjQgMHoiIHRyYW5zZm9ybT0ibWF0cml4KC4zOSAwIDAgLjM5IC02LjEzOCAtMi40NzUpIiBmaWxsPSJ1cmwoI2kpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTM5Ljc3NSAxOS4wMDlhOC42NjIgOC42NjIgMCAxIDEtMTcuMzI0IDAgOC42NjIgOC42NjIgMCAxIDEgMTcuMzI0IDB6IiBmaWxsPSJ1cmwoI2opIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iI2MxN2QxMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgdHJhbnNmb3JtPSJtYXRyaXgoLjM5IDAgMCAuMzkgLTYuMDg5IC0zLjg0KSIvPjxwYXRoIGQ9Ik05LjAwNSAzLjU3MmEyLjk2MiAyLjk2MiAwIDEgMS01LjkyNSAwIDIuOTYyIDIuOTYyIDAgMSAxIDUuOTI1IDB6IiBvcGFjaXR5PSIuMTk2IiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iLjM4OTg4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTIuNDMzIDEyLjA2MmMtLjQ4Ny0uMjEzLS43MDQtLjcyNS0uNzA0LS43MjVDMi4wNTcgOS43NSAzLjE4IDguNTg5IDMuMTggOC41ODlzLS44ODkgMi41LS43NDYgMy40NzN6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2spIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTkuODA2IDExLjcyOGMuNDgtLjIyNy43MDQtLjc4MS43MDQtLjc4MS0uMzc0LTEuNTc3LTEuNTUxLTIuNjY5LTEuNTUxLTIuNjY5cy45NjEgMi40NzQuODQ3IDMuNDV6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2wpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PC9nPjwvc3ZnPg==);background-position:0;background-repeat:no-repeat;line-height:1.125em;margin-top:.5em;padding-left:15px!important;white-space:nowrap}.app-footer{bottom:0;left:0;margin-top:30px;min-height:148px;padding:15px;position:absolute;width:100%}.app-footer hr{margin-bottom:0}.footer-content{padding-top:20px}.footer-about{display:inline;max-width:calc(100% - 200px)}.footer-branding,.footer-quote{white-space:nowrap}.footer-quote{display:inline-block;font-style:italic;max-width:100%;overflow-x:hidden;position:relative;text-overflow:ellipsis;top:8px}.lang-dropdown .dropdown-menu{height:194px;overflow-y:scroll}.navbar-default{background:transparent}.form-fieldset{margin-bottom:15px}.form-fieldset .checkbox{margin-bottom:0}.form-fieldset:nth-child(2){margin-top:-15px}.form-submit{margin-bottom:20px}.form-row>*{border-radius:0}.form-row .input-group-addon label{font-weight:400;margin:0}.form-row:first-child>:first-child{border-top-left-radius:4px}.form-row:first-child>:last-child{border-top-right-radius:4px}.form-row:last-child>:first-child{border-bottom-left-radius:4px}.form-row:last-child>.form-control:last-of-type{border-bottom-right-radius:4px}.form-control[disabled],.form-control[readonly]{background-color:transparent;cursor:not-allowed;opacity:.8}.stat-list{margin-bottom:20px}.stat-list caption{text-align:center}.panel-body .stat-list:only-child{margin-bottom:0}.stat-list>.table td,.stat-list>.table th{border:0;padding-bottom:0;padding-top:0;white-space:normal}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer),.stat-list>.table th:first-child:not(.non-label,.stat-list--footer){font-weight:700;text-align:right}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer):after,.stat-list>.table th:first-child:not(.non-label,.stat-list--footer):after{content:":"}.stat-list--new-group{padding-top:10px!important}.stat-list tr:first-child>td.stat-list--new-group{padding-top:0!important}.stat-list--group{border-top:0!important}.stat-list--group>tr:first-child>td{border-bottom:1px solid #eee!important;padding-top:15px!important;text-align:center!important}.stat-list--group>tr:nth-child(2)>td{padding-top:5px!important}.color-icon{border-radius:100%;display:inline-block;height:15px;vertical-align:-2px;width:15px}.diff-pos{color:#006400}.diff-pos:before{color:#006400;content:"+"}.diff-neg{color:#8b0000}.diff-zero{color:#a2a9b1}.sort-link{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.sort-link .glyphicon{top:2px;visibility:hidden}.sort-link .glyphicon-sort-by-alphabet,.sort-link .glyphicon-sort-by-alphabet-alt,.sort-link:hover .glyphicon-sort{visibility:visible}.assessment-badge{height:20px;width:20px}.rm-inline-margin{margin-right:-4px}.rm-inline-margin-left{margin-left:-4px}.toggle-table{float:left}.toggle-table .toggle-table--toggle{cursor:pointer;margin-right:4px;position:relative}.toggle-table tr:hover .toggle-table--toggle .color-icon{visibility:hidden}.toggle-table tr:hover .toggle-table--toggle .glyphicon{display:block}.toggle-table tr.excluded td.linked a,.toggle-table tr.excluded td:not(.linked){opacity:.5;text-decoration:line-through}.toggle-table tr.excluded .color-icon{opacity:.5}.toggle-table td .glyphicon{display:none;left:1px;position:absolute}.toggle-table--chart{float:left;margin-left:100px;margin-top:20px;max-width:500px}.toggle-table--chart canvas{height:400px;width:400px}.basic-info-charts{clear:both;display:block;padding-top:20px;position:relative}.basic-info-charts canvas:not(#sizechart-canvas){max-width:150px}.basic-info-charts .sizechart-container{height:200px;min-width:650px;position:relative}.basic-info-charts .chart-wrapper{display:flex;float:left;margin-right:50px}.basic-info-charts .chart-legend{align-self:center;margin-left:15px}.basic-info-charts .chart-legend .color-icon{vertical-align:-4px}.display-title *{font-size:inherit!important}.error-wrapper{font-size:18px}.error-wrapper p{margin-bottom:30px}.error-mascot{margin-right:30px;max-width:100%;width:300px}.times-in-utc{margin-top:15px}.download-dropdown{bottom:5px}.download-dropdown .glyphicon-download-alt{top:2px}.multi-select{height:auto}.multi-select .checkbox{display:inline-block;float:left;margin-top:3px;width:33.3333333333%}.user-group-icon{height:18px}a.help-icon{font-size:25px;position:relative;text-decoration:none;top:5px}.help-text{cursor:help;text-decoration:underline dotted}.reverted-edit{background:#fcf8e3!important}.side-to-side>div{display:inline-block;margin:0 10px 15px 0;vertical-align:top}.side-to-side{clear:both}.link-loading{font-style:italic;pointer-events:none}.pageinfo .top-editors-charts{display:block;margin-bottom:25px;position:relative}.pageinfo .top-editors-charts canvas{max-height:250px;max-width:250px}.pageinfo .year-count-charts{clear:both;display:block;margin-bottom:25px;position:relative}.pageinfo .year-count-charts canvas{max-width:800px}.pageinfo .chart-wrapper{display:flex;flex-wrap:wrap;float:left;margin-right:50px}.pageinfo #textshares_chart{display:none;height:auto;width:auto}.pageinfo .chart-title{font-weight:700;text-align:center}.pageinfo .chart-legend{align-self:center;margin-left:15px}.pageinfo .chart-legend .color-icon{vertical-align:-4px}.pageinfo .legend-label{display:inline-block;max-width:200px;overflow-x:hidden;text-overflow:ellipsis;vertical-align:middle}.pageinfo .legend-value{vertical-align:bottom}.pageinfo .top-editors-table{margin-bottom:0}.pageinfo .footnotes{margin-bottom:20px;margin-top:5px}.pageinfo .month-counts-table .stripes-column{height:30px;padding:0}.pageinfo .month-counts-table .stripes{height:33.3333333333%}.pageinfo .sort-entry--assessment,.pageinfo .sort-entry--importance{font-weight:700;text-align:center}.pageinfo .sort-entry--assessment a,.pageinfo .sort-entry--importance a{color:#4365cc}.pageinfo .bugs-table .sort-entry--explanation{min-width:400px;white-space:pre-wrap}@media (max-width:767px){.pageinfo #year_count_legend{margin:0}.pageinfo #year_count_legend .legend-body>div{display:inline;margin-right:10px}}.autoedits #summary .chart-wrapper,.autoedits #summary .chart-wrapper canvas{max-width:300px}.autoedits #summary .chart-wrapper .chart-legend{display:none}.autoedits #tool_chart{display:none;height:auto;width:auto}.autoedits .autoedits-stat-list{margin-bottom:0;padding-bottom:0}.autoedits .tool-selector-form{margin-bottom:20px}.autoedits .tool-selector-form .btn-primary{vertical-align:bottom}.autoedits .tool-selector-form .form-submit{margin-bottom:0}.autoedits .contributions-container--loading .tool-selector-form{visibility:hidden}.autoedits #toolSelector{display:inline-block;width:auto}.autoedits .tooltipcss--tool-counts{cursor:help;position:relative;vertical-align:-1px}.autoedits .tooltipcss--tool-counts .tooltip-body{min-width:300px}.autoedits .footnotes{margin:20px 0 0}.blame .diff{border:0;border-collapse:initial;border-spacing:4px;margin:0;width:100%}.blame .diff td{padding:.33em .5em}.blame .diff td div{word-wrap:break-word}.blame .diff-lineno{font-weight:700}.blame .diff-marker{font-size:1.25em;padding:.25em;text-align:right}.blame .diff-context{background:#f8f9fa;border-color:#eaecf0;color:#222}.blame .diff-deletedline{border-color:#ffe49c}.blame .diff-deletedline .diffchange{background:#feeec8}.blame .diff-addedline{border-color:#a3d3ff}.blame .diff-addedline .diffchange{background:#d8ecff}.blame .diff-addedline,.blame .diff-context,.blame .diff-deletedline{border-radius:.33em;border-style:solid;border-width:1px 1px 1px 4px;line-height:1.6;vertical-align:top;white-space:pre-wrap}.blame .diff-addedline .diffchange,.blame .diff-deletedline .diffchange{border-radius:.33em;padding:.25em 0}.blame .diffchange{text-decoration:none}.categoryedits .toggle-table--chart{margin-left:50px}.categoryedits #category_chart{display:none;height:auto;width:auto}.categoryedits .select2-selection--multiple{border-color:#ccc!important;border-radius:0}.categoryedits .select2-results__option,.categoryedits .select2-selection__rendered{padding-left:12px!important}.categoryedits .select2-results__message{display:none}.categoryedits .select2-results__option--highlighted{background:#3379b7!important}.categoryedits .loading-results{display:none}.editcounter .stat-list .table{margin:0 auto}.editcounter .stat-list .table.rights-changes-summary{margin:0}.editcounter .stat-list--empty-group{font-weight:400!important}.editcounter .stat-list--empty-group:after{content:""!important}.editcounter #timecard-bubble-chart{max-width:1100px}.editcounter #monthcounts-canvas,.editcounter #yearcounts-canvas{max-width:1000px}.editcounter .yearmonth-container{display:flex;flex-direction:row;flex-wrap:wrap;width:100%}.editcounter .yearmonth-chart-container{flex-grow:1}.editcounter #namespace-canvas{height:auto;width:auto}.editcounter .top-project-edit-counts td{white-space:nowrap}.editcounter .footnotes{clear:both;padding-top:20px}.editcounter .tooltipcss--pages-created{position:relative;vertical-align:-1px}.editcounter .tooltipcss--pages-created:hover{text-decoration:none}.editcounter .tooltipcss--pages-created .tooltip-body{min-width:300px}.editcounter .pending{opacity:.5}.home #wrapper{background:transparent}.home .splash-logo{margin-top:30px;max-width:320px;width:100%}.home .tool-list{padding-top:10px;text-align:left}.home .tool-list .btn{display:block;margin-top:15px;position:relative;text-align:left;white-space:normal;width:100%}.home .tool-name{font-weight:700}@media (max-width:767px){.home .splash-logo{max-width:450px;width:90%}}.meta #api_usage_chart,.meta #usage_chart{max-width:1000px}.pages .panel-body h3{clear:both}.pages .panel-body h4:not(:first-child){padding-top:10px}.pages .deleted-page{cursor:help;position:relative}.pages .deleted-page .tooltip-body{left:67%;width:300px}.pages .sort-entry--page-title{white-space:normal}.topedits .color-icon{margin-right:3px}.topedits .footnotes{margin:20px 0}@media (max-width:767px){.basic-info-charts{display:none;margin-bottom:0}}@media (prefers-color-scheme:dark){:root{scrollbar-color:#aaa transparent}#wrapper,.panel-body,.xt-toc,body{background:#181818;color:#aaa}.xt-toc.fixed{box-shadow:5px 5px 8px #333}.home-link:after,[role=separator]{display:none}.dropdown-menu,.dropdown.open,.form-label{background:#222}.dropdown-menu{box-shadow:0 6px 12px #333}.dropdown-menu>.active>a,.dropdown-menu>li>a{background:#222;color:#999}.dropdown-menu>.active>a:hover,.dropdown-menu>li>a:hover{background:#333;color:#999}.navbar-default{border:0}.navbar-default .navbar-nav>li>a{color:#999}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#aaa}header{background:#212121!important}.stat-list--group>tr:first-child>td{border-color:#606060!important}.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-color:#606060}.table-striped>tbody>tr:hover,.table-striped>tbody>tr:nth-of-type(odd){background-color:transparent}.table-sticky-header .sticky-heading{background:#111}a,a:hover{color:#09f}.panel-primary,.panel-primary .panel-heading{border-color:#606060}.help-icon,.help-icon:hover,.panel-primary>.panel-heading,.panel-primary>.panel-heading a,.panel-primary>.panel-heading a:hover,h4{color:#aaa}.panel{background-color:transparent}.form-control,.form-label,.panel-default,.panel-default>.panel-heading,hr{border-color:#333}.form-control:focus{border-color:#606060;box-shadow:inset 0 1px 1px #333,0 0 8px #333}.form-control,.form-label,label{color:#999}.form-control{background:#111;color:#999}.blame .diff-context{background:transparent;color:#aaa}.diffchange-inline{color:#333}.btn-default{background:#222;border-color:#222;color:#999}.btn-default svg{fill:#999}.btn-default:hover svg,.open .btn-default svg{fill:#333}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--multiple .select2-selection__choice{background:transparent}.select2-dropdown{background-color:#222}.categoryedits .select2-selection--multiple{border-color:#333!important}.categoryedits .select2-results__option--highlighted,.select2-container--default .select2-results__option[aria-selected=true]{background-color:#333!important}.alert .close{opacity:.8;text-shadow:none}.alert-info{background:#012;border:0;color:#999}.alert-warning{background-color:#8a6d3b;color:#fcf8e3}.alert-danger{background-color:#a94442;border-color:#ff6b6b;color:#1b1818}.color-icon,canvas{background:transparent}.tooltipcss .tooltip-body{background:#333;border-color:#606060;box-shadow:5px 5px 8px #333;color:#aaa}.tooltipcss .tooltip-body:before{border-color:transparent #606060 transparent transparent}.tooltipcss .tooltip-body:after{border-color:transparent #333 transparent transparent}body.rtl .tooltipcss .tooltip-body:before{border-color:transparent transparent transparent #606060}body.rtl .tooltipcss .tooltip-body:after{border-color:transparent transparent transparent #333}.diff-pos,.diff-pos:before{color:#00b400}.diff-neg{color:#f73d3d}.pagination>li>a{background-color:transparent;border-color:#606060;color:#aaa}.pagination>li>a:focus,.pagination>li>a:hover{background-color:#333;border-color:#606060;color:#aaa}.pagination>li.disabled>span{background-color:inherit;border-color:inherit;opacity:.5}.pagination>li.active>a,.pagination>li.active>a:focus,.pagination>li.active>a:hover{background-color:#333;border-color:#606060;color:#aaa}.reverted-edit{background:#4e4c40!important}.reverted-edit .text-info{color:#74cefa}.reverted-edit .text-muted{color:#909090}.footer-branding,.home-link,.splash-logo{filter:invert(1) hue-rotate(180deg)}}@media (min-width:1200px){.xt-panel-body table{width:auto}}@media (max-width:1200px){.stat-list>.table td:first-child,.stat-list>.table th:first-child{width:300px}}@media (max-width:992px){.error-mascot{width:200px}}@media (max-width:767px){.basic-info-charts .chart-wrapper{display:block!important}.basic-info-charts .chart-wrapper .chart-legend{margin-bottom:15px}.app-footer{position:relative}.footer-branding{float:left!important;margin-bottom:10px}.footer-about{clear:both;display:block;max-width:100%}.footer-quote{overflow-x:initial;text-overflow:clip;white-space:normal}.xt-panel-description{display:block}.stat-list{padding:0}.stat-list>.table td:first-child,.stat-list>.table th:first-child{display:block;padding-top:5px;text-align:left!important}.stat-list>.table td:first-child.stat-list--new-group,.stat-list>.table th:first-child.stat-list--new-group{padding-top:15px!important}.stat-list>.table td .stat-list--new-group,.stat-list>.table td:last-child,.stat-list>.table th .stat-list--new-group,.stat-list>.table th:last-child{display:block;padding-top:0!important}.stat-list--group{padding-top:15px!important}.stat-list--group tr>td{padding-top:5px!important}.stat-list--group>tr:first-child>td{text-align:left!important}.back-to-search{font-size:0}.back-to-search .glyphicon{font-size:medium}.tool-links{clear:both;width:100%}.xt-panel-body{padding:15px 0}.panel-default{border-radius:0}#wrapper{margin:15px 5px}.xt-toc{display:none}.toggle-table--chart{margin:0;max-width:100%}.xt-page-title>small{display:block;padding:3px 0 10px}.xt-page-title>small:before{display:none}.input-group{margin-bottom:15px;width:100%}.input-group .tooltipcss{display:none}.input-group input[type=text]{border-radius:0!important}.input-group-addon{background:transparent;border:0;display:block;padding:0}.input-group-addon:first-child{font-weight:700}.input-group-addon:last-child{clear:both;padding-top:5px}.error-wrapper{padding:10px}.error-mascot--wrapper{margin-bottom:20px;text-align:center;width:100%}.times-in-utc{padding:0 15px}} \ No newline at end of file +@charset "UTF-8";.contributions-nav{margin-bottom:10px}.contributions-nav:last-child{margin-bottom:0}.contributions-limit--wrapper{-webkit-transform:translateY(-5px);-moz-transform:translateY(-5px);-ms-transform:translateY(-5px);-o-transform:translateY(-5px);transform:translateY(-5px)}.contributions-limit--wrapper label{font-weight:400}.contributions-loading{display:none;float:left}.contributions-container--loading{opacity:.3}.contributions-container--loading .contributions-nav{visibility:hidden}.contributions-container--loading .contributions-loading{display:block}html{height:100%}body{background:url(/build/images/gradient.bc9a6010.png);min-height:100%;position:relative}body:after{content:"";display:block;min-height:148px}body.rtl{direction:rtl}body.rtl .tool-links{float:right;padding-right:0!important}body.rtl .tool-links .nav{padding-right:0}body.rtl .tool-links .nav li{float:right}body.rtl .lang-group ul{left:0;right:auto;text-align:right}body.rtl .nav-buttons{float:left!important;margin-left:8px;margin-right:0}body.rtl .navbar-toggle{float:right!important}body.rtl .back-to-search{left:auto;right:0}body.rtl .back-to-search .glyphicon-chevron-left{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}@media (max-width:767px){body.rtl .tool-links li{float:none!important}}body.rtl .form-row:first-child>:first-child{border-top-left-radius:0;border-top-right-radius:4px}body.rtl .form-row:first-child>:last-child{border-top-left-radius:4px;border-top-right-radius:0}body.rtl .input-group-addon:first-child{border-left:0;border-right:1px solid #ccc}body.rtl .form-row:last-child>:first-child{border-bottom-left-radius:0;border-bottom-right-radius:4px}body.rtl .form-row:last-child>:last-child{border-bottom-left-radius:4px;border-bottom-right-radius:0}body.rtl .input-group-addon:last-child{border-left:1px solid #ccc;border-right:0}body.rtl .form-label{text-align:right}body.rtl .form-label .tooltipcss{float:left!important}body.rtl .tooltip-body{margin-left:0;margin-right:28px;text-align:right}body.rtl .tooltip-body:after,body.rtl .tooltip-body:before{left:100%;right:auto}body.rtl .tooltip-body:before{border-color:transparent transparent transparent #dca}body.rtl .tooltip-body:after{border-color:transparent transparent transparent #fffaf0}body.rtl .typeahead{text-align:right}body.rtl .app-footer .footer-about{padding-right:15px}body.rtl .app-footer .footer-branding{float:left!important}body.rtl .table td,body.rtl .table th{text-align:right}@media (min-width:1200px){body.rtl .stat-list{float:right!important}}body.rtl .stat-list>.table td:first-child,body.rtl .stat-list>.table th:first-child{text-align:left}body.rtl .stat-list>.table td:last-child,body.rtl .stat-list>.table th:last-child{text-align:right}@media (max-width:767px){body.rtl .stat-list>.table td:first-child{text-align:right}}body.rtl .panel{text-align:right}body.rtl .toggle-table{float:right}body.rtl .toggle-table--chart{float:right;margin-left:0!important;margin-right:100px!important}body.rtl .chart-legend{margin-left:0;margin-right:15px}body.rtl .chart-legend .color-icon{margin-left:5px;margin-right:0}body.rtl .chart-wrapper{float:right;margin-left:50px;margin-right:0}body.rtl .xt-panel-description{float:left!important}body.rtl .dropdown-menu-right{left:0;right:auto;text-align:right}body.rtl .times-in-utc{float:right}body.rtl.pageinfo .legend-body{text-align:right}body.rtl.pageinfo .sort-entry--assessment,body.rtl.pageinfo .sort-entry--importance{text-align:center}body.rtl.pageinfo .date-range{left:30px;right:auto}body.rtl .rm-inline-margin{margin-left:-4px;margin-right:0}body.rtl .diff-pos:before{display:none}body.rtl .diff-pos:after{color:#006400;content:"+"}body.rtl .side-to-side>div{margin:0 0 15px 10px}.strong{font-weight:700}#wrapper{background:#fff;border-radius:5px;margin:15px}.site-notice{margin:0 15px}.navbar-top{border-top:0;min-height:51px;padding:0}.navbar-top li:last-child{padding-right:0}.tool-links{float:left;padding:0}.tool-links.in{position:relative;top:8px}.tool-links .nav{margin:0}.nav-buttons{left:1px;margin-right:8px;position:relative;top:8px}.nav-buttons li{padding:0 2px}.navbar-nav>li>a{height:50px;line-height:50px;padding:0 10px}.home-link{bottom:1px;padding:0 12px;position:relative}.home-link:after{background:#e7e7e7;bottom:0;content:"";display:inline-block;height:80%;left:100%;margin:auto;position:absolute;top:3px;width:1px}.home-link img{height:32px}.navbar-toggle{left:5px;margin:0;position:relative;top:9px}.lang-group .dropdown-menu{max-height:194px;overflow-y:scroll}.lang-group .btn{padding-left:30px}.lang-group svg{height:17px;left:8px;position:absolute;top:8px;width:17px}.login-btn .glyphicon-user{padding-right:2px;top:2px}.xt-page-title>small:before{content:"•";margin-right:10px}.tooltipcss{position:static;text-decoration:none}.tooltipcss .tooltip-body{background:#fff;border:1px solid #ddd;border-radius:4px;box-shadow:5px 5px 8px #ccc;color:#111;display:none;font-family:Helvetica Neue,Helvetica,Arial,sans-serif;line-height:16px;margin-left:28px;max-width:350px;min-width:200px;padding:14px 20px;text-align:left;white-space:normal;z-index:10}.tooltip-body:after,.tooltip-body:before,.tooltipcss .tooltip-body{position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.tooltip-body:after,.tooltip-body:before{border-style:solid;color:transparent;content:"";display:block;height:0;right:100%;width:0}.tooltip-body:before{border-color:transparent #ddd transparent transparent;border-width:11px}.tooltip-body:after{border-color:transparent #fff transparent transparent;border-width:10px}.tooltipcss:hover .tooltip-body{display:block}.callout{border:0;left:-12px;position:absolute;top:30px;z-index:20}.form-label{min-width:15em;text-align:left;white-space:normal}.xt-heading-top{font-size:1.8em;margin-bottom:0;padding-bottom:0;position:relative}.xt-heading-subtitle{margin-top:3px}.back-to-search{font-size:16px;left:0;position:absolute;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}.back-to-search:hover{text-decoration:none}.date-range{font-size:70%;position:absolute;right:30px;top:50%;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%)}@media (max-width:767px){.date-range{display:block;left:0;position:relative;top:0;transform:none}}.xt-toc{background:#fff;font-size:110%;margin-top:20px;padding:10px;width:100%;z-index:100}.xt-toc span:after{content:" • "}.xt-toc span:last-child:after{content:""}.xt-toc .bold{font-weight:700}.xt-toc.fixed{box-shadow:5px 5px 8px #ccc;left:50%;margin:0;position:fixed;top:0;-webkit-transform:translateX(-50%);-moz-transform:translateX(-50%);-ms-transform:translateX(-50%);-o-transform:translateX(-50%);transform:translateX(-50%);white-space:nowrap;width:auto}.table-sticky-header{position:relative}.table-sticky-header .sticky-heading{background:#fff;left:0;position:absolute;top:0}.xt-alert{margin:0;padding:10px;text-align:center}.xt-alert:not(:first-child){margin-top:10px}.xt-alert .close{opacity:.8;right:0;top:0}.xt-error-alert{margin-top:20px}.xt-error-alert:only-child{margin:0}.panel-primary>.panel-heading a{color:#fff}section>.panel-body{overflow-x:auto}.panel-body .alert:last-child,.panel-body .table:only-child{margin-bottom:0}.xt-panel-body table{clear:both;white-space:nowrap}.xt-panel-body .panel-body{width:100%}.xt-table>tbody>tr>td{padding-bottom:1px;padding-top:1px;vertical-align:top}.xt-table>tbody>tr>th{font-weight:400;padding:5px 2px;white-space:nowrap}.xt-show{display:none}.xt-hide,.xt-show{bottom:2px;cursor:pointer;position:relative}.xt-hide:hover,.xt-show:hover{text-decoration:underline}.xt-panel-description{font-size:80%;font-weight:400;line-height:normal;margin-left:8px}.inline-block{display:inline-block}.table .show-more-row td{border:0}.xt-pagination{margin:0}.panel-danger,.panel-default,.panel-primary{margin-bottom:0}.panel-default{margin-top:20px;text-align:left}.panel-default>.panel-heading{padding:5px 10px}.login{background-image:linear-gradient(transparent,transparent),url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMTIiIGhlaWdodD0iMTMuODM3Ij48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImUiPjxzdG9wIG9mZnNldD0iMCIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1vcGFjaXR5PSIwIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImIiPjxzdG9wIG9mZnNldD0iMCIgc3RvcC1jb2xvcj0iIzNiNzRiYyIvPjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzJkNTk5MCIvPjwvbGluZWFyR3JhZGllbnQ+PGxpbmVhckdyYWRpZW50IGlkPSJjIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNjOWM5YzkiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYSI+PHN0b3Agb2Zmc2V0PSIwIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLW9wYWNpdHk9IjAiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iZCI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjZjRkOWIxIi8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjZGY5NzI1Ii8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgeDE9IjMwLjkzNiIgeTE9IjI5LjU1MyIgeDI9IjMwLjkzNiIgeTI9IjM1LjgwMyIgaWQ9ImgiIHhsaW5rOmhyZWY9IiNjIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIvPjxsaW5lYXJHcmFkaWVudCB4MT0iMjAuNjYyIiB5MT0iMzUuODE4IiB4Mj0iMjIuNjI3IiB5Mj0iMzYuMjE4IiBpZD0iayIgeGxpbms6aHJlZj0iI2UiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0icm90YXRlKDEwLjQ5IDE3LjU2MSAzMi42MykiLz48bGluZWFyR3JhZGllbnQgeDE9IjIyLjY4NyIgeTE9IjM2LjM5IiB4Mj0iMjEuNDA4IiB5Mj0iMzUuNzQiIGlkPSJsIiB4bGluazpocmVmPSIjZSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLS45NzggLjIxIC4yMSAuOTc4IDU1LjExIC0zLjk0NSkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJmIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI4LjA5IiBjeT0iMjcuMjAzIiByPSIxMy41NjUiIGZ4PSIyOC4wOSIgZnk9IjI3LjIwMyIgaWQ9ImciIHhsaW5rOmhyZWY9IiNiIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxLjI5OCAwIDAgLjg4NSAtOC4zNTkgNC45NCkiLz48cmFkaWFsR3JhZGllbnQgY3g9IjMxLjExMyIgY3k9IjE5LjAwOSIgcj0iOC42NjIiIGZ4PSIzMS4xMTMiIGZ5PSIxOS4wMDkiIGlkPSJpIiB4bGluazpocmVmPSIjYSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiLz48cmFkaWFsR3JhZGllbnQgY3g9IjI5LjM0NSIgY3k9IjE3LjA2NCIgcj0iOS4xNjIiIGZ4PSIyOS4zNDUiIGZ5PSIxNy4wNjQiIGlkPSJqIiB4bGluazpocmVmPSIjZCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoLjc4OCAwIDAgLjc4OCA2LjIyMSAzLjYxOCkiLz48L2RlZnM+PGcgY29sb3I9IiMwMDAiPjxwYXRoIGQ9Ik0zOS43NzUgMTkuMDA5YTguNjYyIDguNjYyIDAgMSAxLTE3LjMyNCAwIDguNjYyIDguNjYyIDAgMSAxIDE3LjMyNCAweiIgdHJhbnNmb3JtPSJtYXRyaXgoLjY5MyAwIDAgLjM3NCAtMTUuNTQ4IDMuNDgxKSIgZmlsbD0idXJsKCNmKSIgZmlsbC1ydWxlPSJldmVub2RkIiBvdmVyZmxvdz0idmlzaWJsZSIvPjxwYXRoIGQ9Ik00LjA0NiAxMi4zOThoNC4xMzdjMS4xNzIgMCAyLjMzMi0uNDMgMi43NTgtMS42NTUuNDA0LTEuMTYzLjA2OS0zLjM3OC0yLjU1MS01LjE3MUgzLjQ5NUMuODc1IDcuMjI3LjU0OCA5LjQ4OSAxLjE1MSAxMC44MTJjLjYxNCAxLjM0NyAxLjY1NSAxLjU4NiAyLjg5NiAxLjU4NnoiIGZpbGw9InVybCgjZykiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMjA0YTg3IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBzdHJva2Utd2lkdGg9Ii4zOSIvPjxwYXRoIGQ9Ik00LjMyMSA2LjE5M2MxLjI0MSAxLjEwMyAxLjc5MyA1LjEwMiAxLjc5MyA1LjEwMnMuNTUyLTMuOTk5IDEuNTE3LTUuMTcxbC0zLjMwOS4wNjl6IiBmaWxsPSJ1cmwoI2gpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTUuMjEgNi42MDdzLS44MzkuNjQ4LS43NjcgMS40MjhjLS43OTYtLjcwMi0uODE5LTIuMDQ4LS44MTktMi4wNDhsMS41ODYuNjJ6IiBmaWxsPSIjNzI5ZmNmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0ibTQuMDE4IDExLjk5MiA0LjA5Mi0uMDA5YzEuMDI5IDAgMi4wNDktLjM3NyAyLjQyMi0xLjQ1My4zNTUtMS4wMjItLjAzNy0yLjk2Ny0yLjMzOC00LjU0MmwtNC40OTUtLjA5NUMxLjM5OCA3LjM0Ni45NTIgOS4zMzQgMS40OTEgMTAuNTljLjUzOCAxLjI1NiAxLjMyNCAxLjM5MyAyLjUyNiAxLjQwMXoiIG9wYWNpdHk9Ii4yMTUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuMzkiLz48cGF0aCBkPSJNNi45NDEgNi42MDdzLjgzOS42NDguNzY3IDEuNDI4Yy43OTYtLjcwMi44MTktMi4wNDguODE5LTIuMDQ4bC0xLjU4Ni42MnoiIGZpbGw9IiM3MjlmY2YiIGZpbGwtcnVsZT0iZXZlbm9kZCIgb3ZlcmZsb3c9InZpc2libGUiLz48cGF0aCBkPSJNMzkuNzc1IDE5LjAwOWE4LjY2MiA4LjY2MiAwIDEgMS0xNy4zMjQgMCA4LjY2MiA4LjY2MiAwIDEgMSAxNy4zMjQgMHoiIHRyYW5zZm9ybT0ibWF0cml4KC4zOSAwIDAgLjM5IC02LjEzOCAtMi40NzUpIiBmaWxsPSJ1cmwoI2kpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTM5Ljc3NSAxOS4wMDlhOC42NjIgOC42NjIgMCAxIDEtMTcuMzI0IDAgOC42NjIgOC42NjIgMCAxIDEgMTcuMzI0IDB6IiBmaWxsPSJ1cmwoI2opIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iI2MxN2QxMSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvdmVyZmxvdz0idmlzaWJsZSIgdHJhbnNmb3JtPSJtYXRyaXgoLjM5IDAgMCAuMzkgLTYuMDg5IC0zLjg0KSIvPjxwYXRoIGQ9Ik05LjAwNSAzLjU3MmEyLjk2MiAyLjk2MiAwIDEgMS01LjkyNSAwIDIuOTYyIDIuOTYyIDAgMSAxIDUuOTI1IDB6IiBvcGFjaXR5PSIuMTk2IiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iLjM4OTg4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIG92ZXJmbG93PSJ2aXNpYmxlIiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTIuNDMzIDEyLjA2MmMtLjQ4Ny0uMjEzLS43MDQtLjcyNS0uNzA0LS43MjVDMi4wNTcgOS43NSAzLjE4IDguNTg5IDMuMTggOC41ODlzLS44ODkgMi41LS43NDYgMy40NzN6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2spIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PHBhdGggZD0iTTkuODA2IDExLjcyOGMuNDgtLjIyNy43MDQtLjc4MS43MDQtLjc4MS0uMzc0LTEuNTc3LTEuNTUxLTIuNjY5LTEuNTUxLTIuNjY5cy45NjEgMi40NzQuODQ3IDMuNDV6IiBvcGFjaXR5PSIuMjI4IiBmaWxsPSJ1cmwoI2wpIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIG92ZXJmbG93PSJ2aXNpYmxlIi8+PC9nPjwvc3ZnPg==);background-position:0;background-repeat:no-repeat;line-height:1.125em;margin-top:.5em;padding-left:15px!important;white-space:nowrap}.app-footer{bottom:0;left:0;margin-top:30px;min-height:148px;padding:15px;position:absolute;width:100%}.app-footer hr{margin-bottom:0}.footer-content{padding-top:20px}.footer-about{display:inline;max-width:calc(100% - 200px)}.footer-branding,.footer-quote{white-space:nowrap}.footer-quote{display:inline-block;font-style:italic;max-width:100%;overflow-x:hidden;position:relative;text-overflow:ellipsis;top:8px}.lang-dropdown .dropdown-menu{height:194px;overflow-y:scroll}.navbar-default{background:transparent}.form-fieldset{margin-bottom:15px}.form-fieldset .checkbox{margin-bottom:0}.form-fieldset:nth-child(2){margin-top:-15px}.form-submit{margin-bottom:20px}.form-row>*{border-radius:0}.form-row .input-group-addon label{font-weight:400;margin:0}.form-row:first-child>:first-child{border-top-left-radius:4px}.form-row:first-child>:last-child{border-top-right-radius:4px}.form-row:last-child>:first-child{border-bottom-left-radius:4px}.form-row:last-child>.form-control:last-of-type{border-bottom-right-radius:4px}.form-control[disabled],.form-control[readonly]{background-color:transparent;cursor:not-allowed;opacity:.8}.stat-list{margin-bottom:20px}.stat-list caption{text-align:center}.panel-body .stat-list:only-child{margin-bottom:0}.stat-list>.table td,.stat-list>.table th{border:0;padding-bottom:0;padding-top:0;white-space:normal}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer),.stat-list>.table th:first-child:not(.non-label,.stat-list--footer){font-weight:700;text-align:right}.stat-list>.table td:first-child:not(.non-label,.stat-list--footer):after,.stat-list>.table th:first-child:not(.non-label,.stat-list--footer):after{content:":"}.stat-list--new-group{padding-top:10px!important}.stat-list tr:first-child>td.stat-list--new-group{padding-top:0!important}.stat-list--group{border-top:0!important}.stat-list--group>tr:first-child>td{border-bottom:1px solid #eee!important;padding-top:15px!important;text-align:center!important}.stat-list--group>tr:nth-child(2)>td{padding-top:5px!important}.color-icon{border-radius:100%;display:inline-block;height:15px;vertical-align:-2px;width:15px}.diff-pos{color:#006400}.diff-pos:before{color:#006400;content:"+"}.diff-neg{color:#8b0000}.diff-zero{color:#a2a9b1}.sort-link{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.sort-link .glyphicon{top:2px;visibility:hidden}.sort-link .glyphicon-sort-by-alphabet,.sort-link .glyphicon-sort-by-alphabet-alt,.sort-link:hover .glyphicon-sort{visibility:visible}.assessment-badge{height:20px;width:20px}.rm-inline-margin{margin-right:-4px}.rm-inline-margin-left{margin-left:-4px}.toggle-table{float:left}.toggle-table .toggle-table--toggle{cursor:pointer;margin-right:4px;position:relative}.toggle-table tr:hover .toggle-table--toggle .color-icon{visibility:hidden}.toggle-table tr:hover .toggle-table--toggle .glyphicon{display:block}.toggle-table tr.excluded td.linked a,.toggle-table tr.excluded td:not(.linked){opacity:.5;text-decoration:line-through}.toggle-table tr.excluded .color-icon{opacity:.5}.toggle-table td .glyphicon{display:none;left:1px;position:absolute}.toggle-table--chart{float:left;margin-left:100px;margin-top:20px;max-width:500px}.toggle-table--chart canvas{height:400px;width:400px}.basic-info-charts{clear:both;display:block;padding-top:20px;position:relative}.basic-info-charts canvas:not(#sizechart-canvas){max-width:150px}.basic-info-charts .sizechart-container{height:200px;min-width:650px;position:relative}.basic-info-charts .chart-wrapper{display:flex;float:left;margin-right:50px}.basic-info-charts .chart-legend{align-self:center;margin-left:15px}.basic-info-charts .chart-legend .color-icon{vertical-align:-4px}.display-title *{font-size:inherit!important}.error-wrapper{font-size:18px}.error-wrapper p{margin-bottom:30px}.error-mascot{margin-right:30px;max-width:100%;width:300px}.times-in-utc{margin-top:15px}.download-dropdown{bottom:5px}.download-dropdown .glyphicon-download-alt{top:2px}.multi-select{height:auto}.multi-select .checkbox{display:inline-block;float:left;margin-top:3px;width:33.3333333333%}.user-group-icon{height:18px}a.help-icon{font-size:25px;position:relative;text-decoration:none;top:5px}.help-text{cursor:help;text-decoration:underline dotted}.reverted-edit{background:#fcf8e3!important}.side-to-side>div{display:inline-block;margin:0 10px 15px 0;vertical-align:top}.side-to-side{clear:both}.prp-qualitytext{border-radius:3px;padding:0 2px}.prp-quality0{background-color:#ddd}.prp-quality1{background-color:#ffabab}.prp-quality2{background-color:#bbf}.prp-quality3{background-color:#ffe867}.prp-quality4{background-color:#90ff90}.link-loading{font-style:italic;pointer-events:none}.pageinfo .top-editors-charts{display:block;margin-bottom:25px;position:relative}.pageinfo .top-editors-charts canvas{max-height:250px;max-width:250px}.pageinfo .year-count-charts{clear:both;display:block;margin-bottom:25px;position:relative}.pageinfo .year-count-charts canvas{max-width:800px}.pageinfo .chart-wrapper{display:flex;flex-wrap:wrap;float:left;margin-right:50px}.pageinfo #textshares_chart{display:none;height:auto;width:auto}.pageinfo .chart-title{font-weight:700;text-align:center}.pageinfo .chart-legend{align-self:center;margin-left:15px}.pageinfo .chart-legend .color-icon{vertical-align:-4px}.pageinfo .legend-label{display:inline-block;max-width:200px;overflow-x:hidden;text-overflow:ellipsis;vertical-align:middle}.pageinfo .legend-value{vertical-align:bottom}.pageinfo .top-editors-table{margin-bottom:0}.pageinfo .footnotes{margin-bottom:20px;margin-top:5px}.pageinfo .month-counts-table .stripes-column{height:30px;padding:0}.pageinfo .month-counts-table .stripes{height:33.3333333333%}.pageinfo .sort-entry--assessment,.pageinfo .sort-entry--importance{font-weight:700;text-align:center}.pageinfo .sort-entry--assessment a,.pageinfo .sort-entry--importance a{color:#4365cc}.pageinfo .bugs-table .sort-entry--explanation{min-width:400px;white-space:pre-wrap}@media (max-width:767px){.pageinfo #year_count_legend{margin:0}.pageinfo #year_count_legend .legend-body>div{display:inline;margin-right:10px}}.autoedits #summary .chart-wrapper,.autoedits #summary .chart-wrapper canvas{max-width:300px}.autoedits #summary .chart-wrapper .chart-legend{display:none}.autoedits #tool_chart{display:none;height:auto;width:auto}.autoedits .autoedits-stat-list{margin-bottom:0;padding-bottom:0}.autoedits .tool-selector-form{margin-bottom:20px}.autoedits .tool-selector-form .btn-primary{vertical-align:bottom}.autoedits .tool-selector-form .form-submit{margin-bottom:0}.autoedits .contributions-container--loading .tool-selector-form{visibility:hidden}.autoedits #toolSelector{display:inline-block;width:auto}.autoedits .tooltipcss--tool-counts{cursor:help;position:relative;vertical-align:-1px}.autoedits .tooltipcss--tool-counts .tooltip-body{min-width:300px}.autoedits .footnotes{margin:20px 0 0}.blame .diff{border:0;border-collapse:initial;border-spacing:4px;margin:0;width:100%}.blame .diff td{padding:.33em .5em}.blame .diff td div{word-wrap:break-word}.blame .diff-lineno{font-weight:700}.blame .diff-marker{font-size:1.25em;padding:.25em;text-align:right}.blame .diff-context{background:#f8f9fa;border-color:#eaecf0;color:#222}.blame .diff-deletedline{border-color:#ffe49c}.blame .diff-deletedline .diffchange{background:#feeec8}.blame .diff-addedline{border-color:#a3d3ff}.blame .diff-addedline .diffchange{background:#d8ecff}.blame .diff-addedline,.blame .diff-context,.blame .diff-deletedline{border-radius:.33em;border-style:solid;border-width:1px 1px 1px 4px;line-height:1.6;vertical-align:top;white-space:pre-wrap}.blame .diff-addedline .diffchange,.blame .diff-deletedline .diffchange{border-radius:.33em;padding:.25em 0}.blame .diffchange{text-decoration:none}.categoryedits .toggle-table--chart{margin-left:50px}.categoryedits #category_chart{display:none;height:auto;width:auto}.categoryedits .select2-selection--multiple{border-color:#ccc!important;border-radius:0}.categoryedits .select2-results__option,.categoryedits .select2-selection__rendered{padding-left:12px!important}.categoryedits .select2-results__message{display:none}.categoryedits .select2-results__option--highlighted{background:#3379b7!important}.categoryedits .loading-results{display:none}.editcounter .stat-list .table{margin:0 auto}.editcounter .stat-list .table.rights-changes-summary{margin:0}.editcounter .stat-list--empty-group{font-weight:400!important}.editcounter .stat-list--empty-group:after{content:""!important}.editcounter #timecard-bubble-chart{max-width:1100px}.editcounter #monthcounts-canvas,.editcounter #yearcounts-canvas{max-width:1000px}.editcounter .yearmonth-container{display:flex;flex-direction:row;flex-wrap:wrap;width:100%}.editcounter .yearmonth-chart-container{flex-grow:1}.editcounter #namespace-canvas{height:auto;width:auto}.editcounter div.chart-wrapper.qualitychangechart{float:none}.editcounter .top-project-edit-counts td{white-space:nowrap}.editcounter .footnotes{clear:both;padding-top:20px}.editcounter .tooltipcss--pages-created{position:relative;vertical-align:-1px}.editcounter .tooltipcss--pages-created:hover{text-decoration:none}.editcounter .tooltipcss--pages-created .tooltip-body{min-width:300px}.editcounter .pending{opacity:.5}.home #wrapper{background:transparent}.home .splash-logo{margin-top:30px;max-width:320px;width:100%}.home .tool-list{padding-top:10px;text-align:left}.home .tool-list .btn{display:block;margin-top:15px;position:relative;text-align:left;white-space:normal;width:100%}.home .tool-name{font-weight:700}@media (max-width:767px){.home .splash-logo{max-width:450px;width:90%}}.meta #api_usage_chart,.meta #usage_chart{max-width:1000px}.pages .panel-body h3{clear:both}.pages .panel-body h4:not(:first-child){padding-top:10px}.pages .deleted-page{cursor:help;position:relative}.pages .deleted-page .tooltip-body{left:67%;width:300px}.pages .sort-entry--page-title{white-space:normal}.topedits .color-icon{margin-right:3px}.topedits .footnotes{margin:20px 0}@media (max-width:767px){.basic-info-charts{display:none;margin-bottom:0}}@media (prefers-color-scheme:dark){:root{scrollbar-color:#aaa transparent}#wrapper,.panel-body,.xt-toc,body{background:#181818;color:#aaa}.xt-toc.fixed{box-shadow:5px 5px 8px #333}.home-link:after,[role=separator]{display:none}.dropdown-menu,.dropdown.open,.form-label{background:#222}.dropdown-menu{box-shadow:0 6px 12px #333}.dropdown-menu>.active>a,.dropdown-menu>li>a{background:#222;color:#999}.dropdown-menu>.active>a:hover,.dropdown-menu>li>a:hover{background:#333;color:#999}.navbar-default{border:0}.navbar-default .navbar-nav>li>a{color:#999}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#aaa}header{background:#212121!important}.stat-list--group>tr:first-child>td{border-color:#606060!important}.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-color:#606060}.table-striped>tbody>tr:hover,.table-striped>tbody>tr:nth-of-type(odd){background-color:transparent}.table-sticky-header .sticky-heading{background:#111}a,a:hover{color:#09f}.panel-primary,.panel-primary .panel-heading{border-color:#606060}.help-icon,.help-icon:hover,.panel-primary>.panel-heading,.panel-primary>.panel-heading a,.panel-primary>.panel-heading a:hover,h4{color:#aaa}.panel{background-color:transparent}.form-control,.form-label,.panel-default,.panel-default>.panel-heading,hr{border-color:#333}.form-control:focus{border-color:#606060;box-shadow:inset 0 1px 1px #333,0 0 8px #333}.form-control,.form-label,label{color:#999}.form-control{background:#111;color:#999}.blame .diff-context{background:transparent;color:#aaa}.diffchange-inline{color:#333}.btn-default{background:#222;border-color:#222;color:#999}.btn-default svg{fill:#999}.btn-default:hover svg,.open .btn-default svg{fill:#333}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--multiple .select2-selection__choice{background:transparent}.select2-dropdown{background-color:#222}.categoryedits .select2-selection--multiple{border-color:#333!important}.categoryedits .select2-results__option--highlighted,.select2-container--default .select2-results__option[aria-selected=true]{background-color:#333!important}.alert .close{opacity:.8;text-shadow:none}.alert-info{background:#012;border:0;color:#999}.alert-warning{background-color:#8a6d3b;color:#fcf8e3}.alert-danger{background-color:#a94442;border-color:#ff6b6b;color:#1b1818}.color-icon,canvas{background:transparent}.tooltipcss .tooltip-body{background:#333;border-color:#606060;box-shadow:5px 5px 8px #333;color:#aaa}.tooltipcss .tooltip-body:before{border-color:transparent #606060 transparent transparent}.tooltipcss .tooltip-body:after{border-color:transparent #333 transparent transparent}body.rtl .tooltipcss .tooltip-body:before{border-color:transparent transparent transparent #606060}body.rtl .tooltipcss .tooltip-body:after{border-color:transparent transparent transparent #333}.diff-pos,.diff-pos:before{color:#00b400}.diff-neg{color:#f73d3d}.pagination>li>a{background-color:transparent;border-color:#606060;color:#aaa}.pagination>li>a:focus,.pagination>li>a:hover{background-color:#333;border-color:#606060;color:#aaa}.pagination>li.disabled>span{background-color:inherit;border-color:inherit;opacity:.5}.pagination>li.active>a,.pagination>li.active>a:focus,.pagination>li.active>a:hover{background-color:#333;border-color:#606060;color:#aaa}.reverted-edit{background:#4e4c40!important}.reverted-edit .text-info{color:#74cefa}.reverted-edit .text-muted{color:#909090}.footer-branding,.home-link,.splash-logo{filter:invert(1) hue-rotate(180deg)}.prp-qualitytext{color:#fff}.prp-quality0{background-color:#55585e}.prp-quality1{background-color:#971602}.prp-quality2{background-color:#40408c}.prp-quality3{background-color:#5c5c00}.prp-quality4{background-color:#00662e}}@media (min-width:1200px){.xt-panel-body table{width:auto}}@media (max-width:1200px){.stat-list>.table td:first-child,.stat-list>.table th:first-child{width:300px}}@media (max-width:992px){.error-mascot{width:200px}}@media (max-width:767px){.basic-info-charts .chart-wrapper{display:block!important}.basic-info-charts .chart-wrapper .chart-legend{margin-bottom:15px}.app-footer{position:relative}.footer-branding{float:left!important;margin-bottom:10px}.footer-about{clear:both;display:block;max-width:100%}.footer-quote{overflow-x:initial;text-overflow:clip;white-space:normal}.xt-panel-description{display:block}.stat-list{padding:0}.stat-list>.table td:first-child,.stat-list>.table th:first-child{display:block;padding-top:5px;text-align:left!important}.stat-list>.table td:first-child.stat-list--new-group,.stat-list>.table th:first-child.stat-list--new-group{padding-top:15px!important}.stat-list>.table td .stat-list--new-group,.stat-list>.table td:last-child,.stat-list>.table th .stat-list--new-group,.stat-list>.table th:last-child{display:block;padding-top:0!important}.stat-list--group{padding-top:15px!important}.stat-list--group tr>td{padding-top:5px!important}.stat-list--group>tr:first-child>td{text-align:left!important}.back-to-search{font-size:0}.back-to-search .glyphicon{font-size:medium}.tool-links{clear:both;width:100%}.xt-panel-body{padding:15px 0}.panel-default{border-radius:0}#wrapper{margin:15px 5px}.xt-toc{display:none}.toggle-table--chart{margin:0;max-width:100%}.xt-page-title>small{display:block;padding:3px 0 10px}.xt-page-title>small:before{display:none}.input-group{margin-bottom:15px;width:100%}.input-group .tooltipcss{display:none}.input-group input[type=text]{border-radius:0!important}.input-group-addon{background:transparent;border:0;display:block;padding:0}.input-group-addon:first-child{font-weight:700}.input-group-addon:last-child{clear:both;padding-top:5px}.error-wrapper{padding:10px}.error-mascot--wrapper{margin-bottom:20px;text-align:center;width:100%}.times-in-utc{padding:0 15px}} \ No newline at end of file diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json index 615e9bc44..e22c15d38 100644 --- a/public/build/entrypoints.json +++ b/public/build/entrypoints.json @@ -7,7 +7,7 @@ "/build/app.1d427a43.js" ], "css": [ - "/build/app.7692d209.css" + "/build/app.ed7ba741.css" ] } } diff --git a/public/build/manifest.json b/public/build/manifest.json index aa0bf6aea..a52e201d7 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -1,5 +1,5 @@ { - "build/app.css": "/build/app.7692d209.css", + "build/app.css": "/build/app.ed7ba741.css", "build/app.js": "/build/app.1d427a43.js", "build/runtime.js": "/build/runtime.c217f8c4.js", "build/852.96913092.js": "/build/852.96913092.js", diff --git a/src/Model/AdminScore.php b/src/Model/AdminScore.php index f6dcc6c9a..f7e7ad2ea 100644 --- a/src/Model/AdminScore.php +++ b/src/Model/AdminScore.php @@ -10,6 +10,7 @@ /** * An AdminScore provides scores of logged actions and on-wiki activity made by a user, * to measure if they would be suitable as an administrator. + * @codeCoverageIgnore */ class AdminScore extends Model { /** diff --git a/src/Model/Authorship.php b/src/Model/Authorship.php index 155b40de4..efd11e79a 100644 --- a/src/Model/Authorship.php +++ b/src/Model/Authorship.php @@ -69,6 +69,8 @@ private function getTargetRevId( ?string $target ): ?int { /** * Domains of supported wikis. * @return string[] + * Is a constant. + * @codeCoverageIgnore */ public function getSupportedWikis(): array { return self::SUPPORTED_PROJECTS; @@ -100,18 +102,18 @@ public function getError(): ?string { /** * Get the total number of authors. - * @return int + * @return int|null */ - public function getTotalAuthors(): int { - return $this->data['totalAuthors']; + public function getTotalAuthors(): int|null { + return $this->data['totalAuthors'] ?? null; } /** * Get the total number of characters added. - * @return int + * @return int|null */ - public function getTotalCount(): int { - return $this->data['totalCount']; + public function getTotalCount(): int|null { + return $this->data['totalCount'] ?? null; } /** diff --git a/src/Model/AutoEdits.php b/src/Model/AutoEdits.php index ee8b9b7ba..e279de60b 100644 --- a/src/Model/AutoEdits.php +++ b/src/Model/AutoEdits.php @@ -231,6 +231,8 @@ public function getToolCounts(): array { /** * Get a list of all available tools for the Project. * @return array + * Just passes along a repository result. + * @codeCoverageIgnore */ public function getAllTools(): array { return $this->repository->getTools( $this->project ); diff --git a/src/Model/EditCounter.php b/src/Model/EditCounter.php index 0b64dd0fb..cfd1574f4 100644 --- a/src/Model/EditCounter.php +++ b/src/Model/EditCounter.php @@ -44,10 +44,10 @@ class EditCounter extends Model { protected array $timeCardData; /** - * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'. + * Various data on the last 5000 edits. * @var string[] As returned by the DB, unconverted to int or float */ - protected array $editSizeData; + protected array $editData; /** * Duration of the longest block in seconds; -1 if indefinite, @@ -139,15 +139,17 @@ public function getThanksReceived(): int { * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks. * @return array */ - protected function getBlocks( string $type, bool $blocksOnly = true ): array { + public function getBlocks( string $type, bool $blocksOnly = true ): array { if ( isset( $this->blocks[$type] ) && is_array( $this->blocks[$type] ) ) { - return $this->blocks[$type]; + $blocks = $this->blocks[$type]; + } else { + $method = "getBlocks" . ucfirst( $type ); + $blocks = $this->repository->$method( $this->project, $this->user ); + $this->blocks[$type] = $blocks; } - $method = "getBlocks" . ucfirst( $type ); - $blocks = $this->repository->$method( $this->project, $this->user ); - $this->blocks[$type] = $blocks; // Filter out unblocks unless requested. + // Expressly don't store this. if ( $blocksOnly ) { $blocks = array_filter( $blocks, static function ( $block ) { return $block['log_action'] === 'block' || $block['log_action'] === 'reblock'; @@ -352,7 +354,7 @@ public function getLongestBlockSeconds() { // Reset the last block, as it has now been accounted for. $lastBlock = [ null, null ]; } - } elseif ( $block['log_action'] === 'reblock' && -1 !== $lastBlock[1] ) { + } elseif ( $block['log_action'] === 'reblock' && $lastBlock[1] !== -1 ) { // The last block was modified. // $lastBlock is left unchanged if its duration was indefinite. @@ -726,27 +728,28 @@ public function timeCard(): array { foreach ( $totals as $total ) { $max = max( $max, $total['value'] ); } - foreach ( $totals as &$total ) { - $total['scale'] = round( ( $total['value'] / $max ) * 20 ); + foreach ( $totals as $index => $total ) { + $totals[$index]['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]; + if ( isset( $totals[$index] ) + && (int)$totals[$index]['day_of_week'] === $day + && (int)$totals[$index]['hour'] === $hour + ) { + $sortedTotals[] = $totals[$index]; $index++; } else { - $sortedTotals[$sortedIndex] = [ + $sortedTotals[] = [ 'day_of_week' => $day, 'hour' => $hour, 'value' => 0, ]; } - $sortedIndex++; } } @@ -765,10 +768,12 @@ public function monthCounts( ?DateTime $currentTime = null ): array { return $this->monthCounts; } + // @codeCoverageIgnoreStart // Set to current month if we're not unit-testing if ( !( $currentTime instanceof DateTime ) ) { $currentTime = new DateTime( 'last day of this month' ); } + // @codeCoverageIgnoreEnd $totals = $this->repository->getMonthCounts( $this->project, $this->user ); $out = [ @@ -837,8 +842,6 @@ public function monthCountsWithNamespaces( ?DateTime $currentTime = null ): arra * 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 ) { @@ -861,8 +864,6 @@ private function fillInMonthCounts( array $out, array $totals, DateTime $firstEd * @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 ) { @@ -958,15 +959,15 @@ public function yearTotals( ?DateTime $currentTime = null ): array { } /** - * Get average edit size, and number of large and small edits. - * @return array + * Get average edit size, number of large and small edits, and change tags. + * @return array With keys "sizes", "average_size", "small_edits", "large_edits", "tag_lists". */ - public function getEditSizeData(): array { - if ( !isset( $this->editSizeData ) ) { - $this->editSizeData = $this->repository - ->getEditSizeData( $this->project, $this->user ); + public function getEditData(): array { + if ( !isset( $this->editData ) ) { + $this->editData = $this->repository + ->getEditData( $this->project, $this->user ); } - return $this->editSizeData; + return $this->editData; } /** @@ -979,21 +980,39 @@ public function countLast5000(): int { } /** - * 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 + * Get the ProofreadPage tagged quality changes in the last 5000 edits. + * @return int[] With keys 0, 1, 2, 3, 4, and 'total'. */ - public function countLargeEdits(): int { - $editSizeData = $this->getEditSizeData(); - return isset( $editSizeData['large_edits'] ) ? (int)$editSizeData['large_edits'] : 0; + public function countQualityChanges(): array { + $editData = $this->getEditData(); + $res = [ + 0 => 0, + 1 => 0, + 2 => 0, + 3 => 0, + 4 => 0, + 'total' => 0, + ]; + if ( !isset( $editData['tag_lists'] ) ) { + return $res; + } + $tagLists = $editData['tag_lists']; + foreach ( $tagLists as $list ) { + if ( $list !== null ) { + $found = false; + foreach ( $list as $tag ) { + if ( preg_match( '/^proofreadpage\-quality[0-4]$/', $tag ) ) { + $res[intval( substr( $tag, -1 ) )] += 1; + $found = true; + break; + } + } + if ( $found ) { + $res['total'] += 1; + } + } + } + return $res; } /** @@ -1001,11 +1020,11 @@ public function countLargeEdits(): int { * @return int */ public function countAutoEdits(): int { - $editSizeData = $this->getEditSizeData(); - if ( !isset( $editSizeData['tag_lists'] ) ) { + $editData = $this->getEditData(); + if ( !isset( $editData['tag_lists'] ) ) { return 0; } - $tags = json_decode( $editSizeData['tag_lists'] ); + $tags = $editData['tag_lists']; $autoTags = $this->autoEditsHelper->getTags( $this->project ); return count( // Number @@ -1032,9 +1051,9 @@ public function countAutoEdits(): int { * @return float Size in bytes. */ public function averageEditSize(): float { - $editSizeData = $this->getEditSizeData(); - if ( isset( $editSizeData['average_size'] ) ) { - return round( (float)$editSizeData['average_size'], 3 ); + $editData = $this->getEditData(); + if ( isset( $editData['average_size'] ) ) { + return round( (float)$editData['average_size'], 3 ); } else { return 0; } diff --git a/src/Model/LargestPages.php b/src/Model/LargestPages.php index 7bde25c1b..1282040e5 100644 --- a/src/Model/LargestPages.php +++ b/src/Model/LargestPages.php @@ -50,6 +50,8 @@ public function getExcludePattern(): string { /** * Get the largest pages on the project. * @return Page[] + * Just returns a repository result. + * @codeCoverageIgnore */ public function getResults(): array { return $this->repository->getData( diff --git a/src/Model/Model.php b/src/Model/Model.php index 1ad6a85e5..fe775e732 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -60,8 +60,11 @@ public function setRepository( Repository $repository ): Model { */ public function getRepository(): Repository { if ( !isset( $this->repository ) ) { + // Untestable, Model cannot be directly instantiated and all subclasses set it in __construct. + // @codeCoverageIgnoreStart $msg = sprintf( 'The $repository property for class %s must be set before using.', static::class ); throw new Exception( $msg ); + // @codeCoverageIgnoreEnd } return $this->repository; } diff --git a/src/Model/Page.php b/src/Model/Page.php index 6489ba1f6..c5c1e4b20 100644 --- a/src/Model/Page.php +++ b/src/Model/Page.php @@ -202,7 +202,7 @@ public function getWatchers(): ?int { * 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. + * assumed to be the actual revision ID. If null, use the last revision. * @return string */ // phpcs:ignore MediaWiki.Usage.NullableType.ExplicitNullableTypes @@ -287,21 +287,19 @@ public function getNumRevisions( ?User $user = null, false|int $start = false, f * @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 + ?int $limit = null ): array { if ( isset( $this->revisions ) ) { return $this->revisions; } - $this->revisions = $this->repository->getRevisions( $this, $user, $start, $end, $limit, $numRevisions ); + $this->revisions = $this->repository->getRevisions( $this, $user, $start, $end, $limit ); return $this->revisions; } @@ -324,32 +322,27 @@ public function getWikitext(): ?string { * @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 + * Just returns a Repo result. + * @codeCoverageIgnore */ 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 ); + return $this->repository->getRevisionsStmt( $this, $user, $limit, $start, $end ); } /** * Get the revision ID that immediately precedes the given date. * @param DateTime $date * @return int|null Null if none found. + * Just returns a Repo result. + * @codeCoverageIgnore */ public function getRevisionIdAtDate( DateTime $date ): ?int { return $this->repository->getRevisionIdAtDate( $this, $date ); diff --git a/src/Model/PageAssessments.php b/src/Model/PageAssessments.php index dc59ddbaf..36e037eab 100644 --- a/src/Model/PageAssessments.php +++ b/src/Model/PageAssessments.php @@ -102,7 +102,7 @@ public function getBadgeURL( ?string $class, bool $filenameOnly = false ): strin } elseif ( isset( $config['class']['Unknown'] ) ) { $url = 'https://upload.wikimedia.org/wikipedia/commons/' . $config['class']['Unknown']['badge']; } else { - $url = ""; + $url = ''; } if ( $filenameOnly ) { @@ -256,7 +256,7 @@ private function getClassFromAssessment( array $assessment ): array { * @param array $assessment * @return array|null Decorated importance assessment. Null if importance could not be determined. */ - private function getImportanceFromAssessment( array $assessment ): ?array { + public function getImportanceFromAssessment( array $assessment ): ?array { $importanceValue = $assessment['importance']; if ( $importanceValue == '' && !isset( $this->getConfig()['importance'] ) ) { diff --git a/src/Model/PageInfo.php b/src/Model/PageInfo.php index bfcd56ec5..c20856ea0 100644 --- a/src/Model/PageInfo.php +++ b/src/Model/PageInfo.php @@ -160,7 +160,6 @@ public function getNumRevisionsProcessed(): int { /** * Fetch and store all the data we need to show the PageInfo view. - * @codeCoverageIgnore */ public function prepareData(): void { $this->parseHistory(); @@ -390,6 +389,8 @@ public function getMaxDeletion(): ?Edit { /** * Get the subpage count. * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function getSubpageCount(): int { return $this->repository->getSubpageCount( $this->page ); @@ -436,7 +437,7 @@ public function topTenEditorsByAdded(): array { * @return array */ public function getYearMonthCounts(): array { - return $this->yearMonthCounts; + return $this->yearMonthCounts ?? []; } /** @@ -475,10 +476,6 @@ public function getTools(): array { /** * 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; @@ -519,18 +516,6 @@ private function parseHistory(): void { /** @var Edit $edit */ $edit = $this->repository->getEdit( $this->page, $rev ); - if ( $edit->getDeleted() !== 0 ) { - $this->numDeletedRevisions++; - } - - if ( in_array( 'mobile edit', $edit->getTags() ) ) { - $this->mobileCount++; - } - - if ( in_array( 'visualeditor', $edit->getTags() ) ) { - $this->visualCount++; - } - if ( $revCount === 0 ) { $this->firstEdit = $edit; } @@ -549,7 +534,10 @@ private function parseHistory(): void { // Various sorts arsort( $this->editors ); - ksort( $this->yearMonthCounts ); + if ( isset( $this->yearMonthCounts ) ) { + // Might not be if there are no edits + ksort( $this->yearMonthCounts ); + } if ( $this->tools ) { arsort( $this->tools ); } @@ -580,6 +568,18 @@ private function updateCounts( Edit $edit, array $prevEdits ): array { // Update figures regarding content addition/removal, and the revert count. $prevEdits = $this->updateContentSizes( $edit, $prevEdits ); + if ( $edit->getDeleted() !== 0 ) { + $this->numDeletedRevisions++; + } + + if ( in_array( 'mobile edit', $edit->getTags() ) ) { + $this->mobileCount++; + } + + if ( in_array( 'visualeditor', $edit->getTags() ) ) { + $this->visualCount++; + } + // Now that we've updated all the counts, we can reset // the prev and last edits, which are used for tracking. // But first, let's copy over the SHA of the actual previous edit @@ -606,6 +606,7 @@ private function updateContentSizes( Edit $edit, array $prevEdits ): array { $edit->setReverted( true ); return $this->updateContentSizesRevert( $prevEdits ); } else { + $edit->setReverted( false ); return $this->updateContentSizesNonRevert( $edit, $prevEdits ); } } @@ -663,7 +664,7 @@ private function updateContentSizesRevert( array $prevEdits ): array { * @return Edit[] Updated version of $prevEdits, for tracking. */ private function updateContentSizesNonRevert( Edit $edit, array $prevEdits ): array { - $editSize = $this->getEditSize( $edit, $prevEdits ); + $editSize = $edit->getSize(); // Edit was not a revert, so treat size > 0 as content added. if ( $editSize > 0 ) { @@ -692,24 +693,6 @@ private function updateContentSizesNonRevert( Edit $edit, array $prevEdits ): ar return $prevEdits; } - /** - * Get the size of the given edit, based on the previous edit (if present). - * We also don't return the actual edit size if last revision had a length of null. - * This happens when the edit follows other edits that were revision-deleted. - * @see T148857 for more information. - * @todo Remove once T101631 is resolved. - * @param Edit $edit - * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion'. - * @return int|null - */ - private function getEditSize( Edit $edit, array $prevEdits ): ?int { - if ( $prevEdits['prev'] && $prevEdits['prev']->getLength() === null ) { - return 0; - } else { - return $edit->getSize(); - } - } - /** * Update counts of automated tool usage for the given edit. * @param Edit $edit @@ -954,9 +937,8 @@ private function doPostPrecessing(): void { } // Compute the percentage of minor edits the user made. - $this->editors[$editor]['minorPercentage'] = $info['all'] - ? ( $info['minor'] / $info['all'] ) * 100 - : 0; + // Note: $info['all'] is ensured to be non-null because we ++ it as soon as we add the editor + $this->editors[$editor]['minorPercentage'] = ( $info['minor'] / $info['all'] ) * 100; if ( $info['all'] > 1 ) { // Number of seconds/days between first and last edit. diff --git a/src/Model/PageInfoApi.php b/src/Model/PageInfoApi.php index cf3641a3a..67421a55d 100644 --- a/src/Model/PageInfoApi.php +++ b/src/Model/PageInfoApi.php @@ -92,6 +92,8 @@ public function tooManyRevisions(): bool { * intentionally disabled, because using the gadget, this will get hit for a different page constantly, where * the likelihood of cache benefiting us is slim. * @return string[]|false false if the page was not found. + * Just returns a repository result. + * @codeCoverageIgnore */ public function getBasicEditingInfo() { return $this->repository->getBasicEditingInfo( $this->page ); @@ -104,10 +106,8 @@ public function getBasicEditingInfo() { * @return array */ public function getTopEditorsByEditCount( int $limit = 20, bool $noBots = false ): array { - // Quick cache, valid only for the same request. - static $topEditors = null; - if ( $topEditors !== null ) { - return $topEditors; + if ( isset( $this->topEditors ) ) { + return $this->topEditors; } $rows = $this->repository->getTopEditorsByEditCount( @@ -118,10 +118,10 @@ public function getTopEditorsByEditCount( int $limit = 20, bool $noBots = false $noBots ); - $topEditors = []; + $this->topEditors = []; $rank = 0; foreach ( $rows as $row ) { - $topEditors[] = [ + $this->topEditors[] = [ 'rank' => ++$rank, 'username' => $row['username'], 'count' => $row['count'], @@ -139,7 +139,7 @@ public function getTopEditorsByEditCount( int $limit = 20, bool $noBots = false ]; } - return $topEditors; + return $this->topEditors; } /** @@ -462,6 +462,8 @@ public function getNumBots(): int { /** * Get counts of (semi-)automated tools used to edit the page. * @return array + * Just returns a repository result. + * @codeCoverageIgnore */ public function getAutoEditsCounts(): array { return $this->repository->getAutoEditsCounts( $this->page, $this->start, $this->end ); diff --git a/src/Model/Pages.php b/src/Model/Pages.php index e70452b2d..0a8a1033d 100644 --- a/src/Model/Pages.php +++ b/src/Model/Pages.php @@ -34,9 +34,6 @@ class Pages extends Model { /** @var array Number of redirects/pages that were created/deleted, broken down by namespace. */ protected array $countsByNamespace; - /** @var bool Whether to only get the counts */ - protected bool $countsOnly; - /** * Pages constructor. * @param Repository|PagesRepository $repository @@ -60,12 +57,11 @@ public function __construct( protected int|false $start = false, protected int|false $end = false, protected int|false $offset = false, - bool $countsOnly = false, + protected bool $countsOnly = false, ) { $this->namespace = $namespace === 'all' ? 'all' : (int)$namespace; $this->redirects = $redirects ?: self::REDIR_NONE; $this->deleted = $deleted ?: self::DEL_ALL; - $this->countsOnly = $countsOnly; } /** @@ -259,6 +255,11 @@ public function getCounts(): array { if ( self::REDIR_NONE !== $this->redirects ) { $counts[$ns]['redirects'] = (int)$row['redirects']; } + if ( $this->project->isPrpPage( $ns ) ) { + foreach ( [ 0, 1, 2, 3, 4 ] as $level ) { + $counts[$ns]["prp_quality$level"] = (int)$row["prp_quality$level"]; + } + } } $this->countsByNamespace = $counts; @@ -282,11 +283,9 @@ public function getAssessmentCounts(): array { foreach ( $this->pages as $ns => $nsPages ) { if ( $this->project->hasPageAssessments( $ns ) ) { foreach ( $nsPages as $page ) { - if ( !isset( $counts[$page['assessment']['class'] ?: 'Unknown'] ) ) { - $counts[$page['assessment']['class'] ?: 'Unknown'] = 1; - } else { - $counts[$page['assessment']['class'] ?: 'Unknown']++; - } + $class = $page['assessment']['class'] ?: 'Unknown'; + $counts[$class] ??= 0; + $counts[$class]++; } } } @@ -481,6 +480,10 @@ private function formatPages( array $pages ): array { ]; } + if ( array_key_exists( 'prp_quality', $row ) ) { + $pageData['prp_quality'] = (int)$row['prp_quality']; + } + $results[$row['namespace']][] = $pageData; } diff --git a/src/Model/Project.php b/src/Model/Project.php index 6647fcb5b..4e80cee72 100644 --- a/src/Model/Project.php +++ b/src/Model/Project.php @@ -63,6 +63,31 @@ public function hasPageAssessments( int|string|null $nsId = null ): bool { } } + /** + * Whether or not this namespace is the Page namespace (of ProofreadPage). + * Or true if it is 'all'. + * @param int|string $namespace Namespace ID, or 'all'. + * @return bool + */ + public function isPrpPage( $namespace ): bool { + return $this->hasProofreadPage() && + ( + !is_numeric( $namespace ) || + $this->getCanonicalNamespace( $namespace ) === 'Page' + ); + } + + /** + * Get the list of the names of each ProofreadPage + * quality level. Keys are 0, 1, 2, 3, and 4. + * @return string[] + * Just returns a Repository result. + * @codeCoverageIgnore + */ + public function getPrpQualityNames(): array { + return $this->repository->getPrpQualityNames( $this ); + } + /** * Unique identifier this Project, to be used in cache keys. * @see Repository::getCacheKey() @@ -215,7 +240,22 @@ public function getTitle(): string { */ public function getNamespaces(): array { $metadata = $this->getMetadata(); - return $metadata['namespaces']; + return ( $metadata === null ? [] : $metadata['namespaces'] ); + } + + /** + * Get the canonical namespace name for a namespace ID. + * Or '' if the namespace does not exist. + * @param int $namespace + * @return string + */ + public function getCanonicalNamespace( int $namespace ): string { + $canonicalNamespaces = $this->getMetadata()['canonical_namespaces']; + if ( array_key_exists( $namespace, $canonicalNamespaces ) ) { + return $canonicalNamespaces[$namespace]; + } else { + return ''; + } } /** @@ -232,14 +272,12 @@ public function getMainPage(): string { * @return string[] */ public function getInstalledExtensions(): array { - // Quick cache, valid only for the same request. - static $installedExtensions = null; - if ( is_array( $installedExtensions ) ) { - return $installedExtensions; + if ( !isset( $this->installedExtensions ) || + !is_array( $this->installedExtensions ) + ) { + $this->installedExtensions = $this->getRepository()->getInstalledExtensions( $this ); } - - $installedExtensions = $this->getRepository()->getInstalledExtensions( $this ); - return $installedExtensions; + return $this->installedExtensions; } /** @@ -251,6 +289,15 @@ public function hasPageTriage(): bool { return in_array( 'PageTriage', $extensions ); } + /** + * Get if this Wiki has the ProofreadPage extension. + * @return bool + */ + public function hasProofreadPage(): bool { + $extensions = $this->getInstalledExtensions(); + return in_array( 'ProofreadPage', $extensions ); + } + /** * Whether this wiki has the VisualEditor extension enabled. * @return bool diff --git a/src/Model/TopEdits.php b/src/Model/TopEdits.php index 09d0aa168..0ef70e1a1 100644 --- a/src/Model/TopEdits.php +++ b/src/Model/TopEdits.php @@ -148,6 +148,7 @@ public function getNumTopEdits(): int { * Get the WikiProject totals. * @param int $ns Namespace ID. * @return array + * @codeCoverageIgnore Just returns a repository result. */ public function getProjectTotals( int $ns ): array { return $this->repository->getProjectTotals( diff --git a/src/Model/User.php b/src/Model/User.php index acfe5449d..1de2b63cc 100644 --- a/src/Model/User.php +++ b/src/Model/User.php @@ -191,6 +191,8 @@ public function getId( Project $project ): ?int { * Get the user's actor ID on the given project. * @param Project $project * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function getActorId( Project $project ): int { return (int)$this->repository->getActorId( @@ -228,6 +230,8 @@ public function getUserRights( Project $project ): array { * Get a list of this user's global rights. * @param Project|null $project A project to query; if not provided, the default will be used. * @return string[] + * Just returns a repository result. + * @codeCoverageIgnore */ public function getGlobalUserRights( ?Project $project = null ): array { return $this->repository->getGlobalUserRights( $this->getUsername(), $project ); @@ -255,6 +259,8 @@ public function getEditCount( Project $project ): int { /** * Number of edits which if exceeded, will require the user to log in. * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function numEditsRequiringLogin(): int { return $this->repository->numEditsRequiringLogin(); @@ -280,6 +286,8 @@ public function existsOnProject( Project $project ): bool { /** * Does this user exist globally? * @return bool + * Just returns a repository result. + * @codeCoverageIgnore */ public function existsGlobally(): bool { return $this->repository->existsGlobally( $this ); @@ -401,6 +409,8 @@ public function hasTooManyEdits( Project $project ): bool { * @param false|int $start Start date as Unix timestamp. * @param int|false $end End date as Unix timestamp. * @return int + * Just returns a repository result. + * @codeCoverageIgnore */ public function countEdits( Project $project, diff --git a/src/Model/UserRights.php b/src/Model/UserRights.php index 2dbcf5686..0d0fcd490 100644 --- a/src/Model/UserRights.php +++ b/src/Model/UserRights.php @@ -9,7 +9,6 @@ use App\Repository\UserRightsRepository; use DateInterval; use DateTimeImmutable; -use Exception; /** * An UserRights provides methods around parsing changes to a user's rights. @@ -282,18 +281,13 @@ private function processRightsChanges( array $logData ): array { } else { // This is the old school format that most likely contains // the list of rights additions as a comma-separated list. - try { - [ $old, $new ] = explode( "\n", $row['log_params'] ); - $old = array_filter( array_map( 'trim', explode( ',', $old ) ) ); - $new = array_filter( array_map( 'trim', explode( ',', (string)$new ) ) ); - $added = array_diff( $new, $old ); - $removed = array_diff( $old, $new ); - } catch ( Exception ) { - // Really, really old school format that may be missing metadata - // altogether. Here we'll just leave $added and $removed empty. - $added = []; - $removed = []; - } + // NB: none of these functions throw as long as log_params is a string or int, + // and we know it is. + [ $old, $new ] = explode( "\n", $row['log_params'] ); + $old = array_filter( array_map( 'trim', explode( ',', $old ) ) ); + $new = array_filter( array_map( 'trim', explode( ',', (string)$new ) ) ); + $added = array_diff( $new, $old ); + $removed = array_diff( $old, $new ); } // Remove '(none)'. @@ -405,10 +399,9 @@ public function hasImpossibleLogs(): bool { * Get the timestamp of when the user became autoconfirmed. * @return string|false YmdHis format, or false if date is in the future or if AC status could not be determined. */ - private function getAutoconfirmedTimestamp(): false|string { - static $acTimestamp = null; - if ( $acTimestamp !== null ) { - return $acTimestamp; + public function getAutoconfirmedTimestamp(): false|string { + if ( isset( $this->acTimestamp ) && $this->acTimestamp !== null ) { + return $this->acTimestamp; } if ( $this->user->isTemp( $this->project ) ) { @@ -447,18 +440,25 @@ private function getAutoconfirmedTimestamp(): false|string { // If more than wgAutoConfirmCount, then $acDate is when they became autoconfirmed. if ( $editsByAcDate >= $thresholds['wgAutoConfirmCount'] ) { - return $acDate; + $this->acTimestamp = $acDate; + } else { + // Now check when the nth edit was made, where n is wgAutoConfirmCount. + // This will be false if they still haven't made 10 edits. + $this->acTimestamp = $this->repository->getNthEditTimestamp( + $this->project, + $this->user, + $registrationDate->format( 'YmdHis' ), + $thresholds['wgAutoConfirmCount'] + ); + if ( $this->acTimestamp ) { + if ( is_string( $this->acTimestamp ) ) { + $this->acTimestamp = ( new \DateTime( $this->acTimestamp ) )->format( 'YmdHis' ); + } else { + $this->acTimestamp = $this->acTimestamp->format( 'YmdHis' ); + } + } } - // Now check when the nth edit was made, where n is wgAutoConfirmCount. - // This will be false if they still haven't made 10 edits. - $acTimestamp = $this->repository->getNthEditTimestamp( - $this->project, - $this->user, - $registrationDate->format( 'YmdHis' ), - $thresholds['wgAutoConfirmCount'] - ); - - return $acTimestamp; + return $this->acTimestamp; } } diff --git a/src/Repository/AdminStatsRepository.php b/src/Repository/AdminStatsRepository.php index d969e87c5..ae0a92db7 100644 --- a/src/Repository/AdminStatsRepository.php +++ b/src/Repository/AdminStatsRepository.php @@ -188,7 +188,6 @@ private function getUserGroupByLocality( array $res, array $permissions, bool $g // If they are able to add and remove user groups, we'll treat them as having the 'userrights' permission. if ( isset( $userGroup['add'] ) || isset( $userGroup['remove'] ) ) { $userGroup['rights'][] = 'userrights'; - $userGroup['rights'][] = 'userrights'; } if ( count( array_intersect( $userGroup['rights'], $permissions ) ) > 0 ) { $userGroups[] = $userGroup['name']; diff --git a/src/Repository/EditCounterRepository.php b/src/Repository/EditCounterRepository.php index 3bdbf3e95..61dc4c393 100644 --- a/src/Repository/EditCounterRepository.php +++ b/src/Repository/EditCounterRepository.php @@ -526,9 +526,10 @@ public function getTimeCard( Project $project, User $user ): array { * Will cache the result for 10 minutes. * @param Project $project The project. * @param User $user The user. - * @return string[] With keys 'average_size', 'small_edits' and 'large_edits' + * @return string[] With keys 'sizes, 'average_size', + * 'small_edits', 'large_edits', and 'tag_lists' */ - public function getEditSizeData( Project $project, User $user ): array { + public function getEditData( Project $project, User $user ): array { // Set up cache. $cacheKey = $this->getCacheKey( func_get_args(), 'ec_editsizes' ); if ( $this->cache->hasItem( $cacheKey ) ) { @@ -552,26 +553,27 @@ public function getEditSizeData( Project $project, User $user ): array { } $sql = "SELECT JSON_ARRAYAGG(data.size) as sizes, - JSON_ARRAYAGG(data.tags) as tag_lists - FROM ( - SELECT CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0) AS size, - ( - SELECT JSON_ARRAYAGG(ctd_name) - FROM $ctTable - JOIN $ctdTable - ON ct_tag_id = ctd_id - WHERE ct_rev_id = revs.rev_id - ) as tags - FROM $revisionTable AS revs - JOIN $pageTable ON revs.rev_page = page_id - $ipcJoin - LEFT JOIN $revisionTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id) - WHERE $whereClause - ORDER BY revs.rev_timestamp DESC - LIMIT 5000 - ) data"; + JSON_ARRAYAGG(data.tags) as tag_lists + FROM ( + SELECT CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0) AS size, + ( + SELECT JSON_ARRAYAGG(ctd_name) + FROM $ctTable + JOIN $ctdTable + ON ct_tag_id = ctd_id + WHERE ct_rev_id = revs.rev_id + ) as tags + FROM $revisionTable AS revs + JOIN $pageTable ON revs.rev_page = page_id + $ipcJoin + LEFT JOIN $revisionTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id) + WHERE $whereClause + ORDER BY revs.rev_timestamp DESC + LIMIT 5000 + ) data"; $results = $this->executeProjectsQuery( $project, $sql, $params )->fetchAssociative(); - $results['sizes'] = json_decode( $results['sizes'] ?? '[]' ); + $results['tag_lists'] = json_decode( $results['tag_lists'] ); + $results['sizes'] = json_decode( $results['sizes'] ); $results['average_size'] = count( $results['sizes'] ) > 0 ? array_sum( $results['sizes'] ) / count( $results['sizes'] ) : 0; diff --git a/src/Repository/PageRepository.php b/src/Repository/PageRepository.php index f5e07203a..cb8f9621e 100644 --- a/src/Repository/PageRepository.php +++ b/src/Repository/PageRepository.php @@ -100,7 +100,6 @@ public function getPagesWikitext( Project $project, array $pageTitles ): array { * @param false|int $start * @param false|int $end * @param int|null $limit - * @param int|null $numRevisions * @return string[] Each member with keys: id, timestamp, length, * minor, length_change, user_id, username, comment, sha, deleted, tags. */ @@ -110,14 +109,13 @@ public function getRevisions( false|int $start = false, false|int $end = false, ?int $limit = null, - ?int $numRevisions = null ): array { $cacheKey = $this->getCacheKey( func_get_args(), 'page_revisions' ); if ( $this->cache->hasItem( $cacheKey ) ) { return $this->cache->getItem( $cacheKey )->get(); } - $stmt = $this->getRevisionsStmt( $page, $user, $limit, $numRevisions, $start, $end ); + $stmt = $this->getRevisionsStmt( $page, $user, $limit, $start, $end ); $result = $stmt->fetchAllAssociative(); // Cache and return. @@ -129,9 +127,6 @@ public function getRevisions( * @param Page $page The page. * @param User|null $user Specify to get only revisions by the given user. * @param ?int $limit Max number of revisions to process. - * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the - * OFFSET if we are given a $limit (see below). If $limit is set and $numRevisions is not set, - * a separate query is ran to get the number of revisions. * @param false|int $start * @param false|int $end * @return Result @@ -140,7 +135,6 @@ public function getRevisionsStmt( Page $page, ?User $user = null, ?int $limit = null, - ?int $numRevisions = null, false|int $start = false, false|int $end = false ): Result { diff --git a/src/Repository/PagesRepository.php b/src/Repository/PagesRepository.php index 704fdfe76..86df05d14 100644 --- a/src/Repository/PagesRepository.php +++ b/src/Repository/PagesRepository.php @@ -48,17 +48,26 @@ public function countPagesCreated( $conditions = array_merge( $conditions, $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), - $this->getUserConditions( ( $start . $end ) !== '' ) + $this->getUserConditions( ( $start . $end ) !== '' ), + $this->getPrpConditions( $namespace, $project ) ); $wasRedirect = $this->getWasRedirectClause( $redirects, $deleted ); $summation = Pages::DEL_NONE !== $deleted ? 'redirect OR was_redirect' : 'redirect'; + $prpSelect = ''; + if ( $project->isPrpPage( $namespace ) ) { + foreach ( [ 0, 1, 2, 3, 4 ] as $level ) { + $prpSelect .= ", SUM(IF(`prp_quality` = $level, 1, 0)) AS `prp_quality$level`"; + } + } + $sql = "SELECT `namespace`, COUNT(page_title) AS `count`, SUM(IF(type = 'arc', 1, 0)) AS `deleted`, SUM($summation) AS `redirects`, SUM(rev_length) AS `total_length` + $prpSelect FROM (" . $this->getPagesCreatedInnerSql( $project, $conditions, $deleted, $start, $end, false, true ) . " ) a " . @@ -111,7 +120,8 @@ public function getPagesCreated( $conditions = array_merge( $conditions, $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), - $this->getUserConditions( $start . $end !== '' ) + $this->getUserConditions( $start . $end !== '' ), + $this->getPrpConditions( $namespace, $project ) ); $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); @@ -131,7 +141,7 @@ public function getPagesCreated( ON pa_project_id = pap_project_id WHERE pa_page_id = page_id ) AS pap_project_title"; - $conditions['paSelectsArchive'] = ', NULL AS pa_class, NULL as pap_project_title'; + $conditions['paSelectsArchive'] = ', NULL AS pa_class, NULL AS pap_project_title'; $conditions['revPageGroupBy'] = 'GROUP BY rev_page'; } @@ -190,6 +200,28 @@ private function getNamespaceRedirectAndDeletedPagesConditions( string|int $name return $conditions; } + /** + * Get SQL fragments for ProofreadPage quality. + * @param int|string $namespace + * @param Project $project + * @return string[] With keys 'prpSelect', 'prpArSelect' and 'prpJoin' + */ + private function getPrpConditions( int|string $namespace, Project $project ): array { + $conditions = [ + 'prpSelect' => '', + 'prpArSelect' => '', + 'prpJoin' => '', + ]; + if ( $project->isPrpPage( $namespace ) ) { + $pagePropsTable = $project->getTableName( 'page_props', '' ); + $conditions['prpSelect'] = ", pp_value AS `prp_quality`"; + $conditions['prpArSelect'] = ", NULL AS `prp_quality`"; + $conditions['prpJoin'] = "LEFT OUTER JOIN $pagePropsTable + ON (pp_page, pp_propname) = (page_id, 'proofread_page_quality_level')"; + } + return $conditions; + } + /** * Inner SQL for getting or counting pages created by the user. * @param Project $project @@ -218,7 +250,7 @@ private function getPagesCreatedInnerSql( $logTable = $project->getTableName( 'logging', 'logindex' ); // Only SELECT things that are needed, based on whether or not we're doing a COUNT. - $revSelects = "DISTINCT page_namespace AS `namespace`, 'rev' AS `type`, page_title, " + $revSelects = "page_namespace AS `namespace`, 'rev' AS `type`, page_title, " . "page_is_redirect AS `redirect`, rev_len AS `rev_length`"; if ( !$count ) { $revSelects .= ", page_len AS `length`, rev_timestamp AS `timestamp`, " @@ -232,10 +264,11 @@ private function getPagesCreatedInnerSql( $tagDefTable = $project->getTableName( 'change_tag_def' ); $revisionsSelect = " - SELECT $revSelects " . $conditions['paSelects'] . ", + SELECT $revSelects " . $conditions['paSelects'] . $conditions['prpSelect'] . ", NULL AS was_redirect FROM $pageTable JOIN $revisionTable ON page_id = rev_page + " . $conditions['prpJoin'] . " WHERE " . $conditions['whereRev'] . " AND rev_parent_id = '0'" . $conditions['namespaceRev'] . @@ -256,7 +289,7 @@ private function getPagesCreatedInnerSql( } $archiveSelect = " - SELECT $arSelects " . $conditions['paSelectsArchive'] . ", + SELECT $arSelects " . $conditions['paSelectsArchive'] . $conditions['prpArSelect'] . ", ( SELECT 1 FROM $tagTable diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 78603d61e..ae5ad6bf5 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -253,6 +253,7 @@ public function getMetadata( string $projectUrl ): ?array { $metadata = [ 'general' => [], 'namespaces' => [], + 'canonical_namespaces' => [], 'tempAccountPatterns' => $res['query']['autocreatetempuser']['matchPatterns'] ?? null, ]; @@ -300,10 +301,13 @@ private function setNamespaces( array $res, array &$metadata ): void { } elseif ( isset( $namespace['*'] ) ) { $name = $namespace['*']; } else { - continue; + $name = null; } - $metadata['namespaces'][$namespace['id']] = $name; + if ( $name !== null ) { + $metadata['namespaces'][$namespace['id']] = $name; + } + $metadata['canonical_namespaces'][$namespace['id']] = $namespace['canonical'] ?? ''; } } @@ -351,6 +355,10 @@ public function pageHasContent( Project $project, int $namespaceId, string $page * @return string[] */ public function getInstalledExtensions( Project $project ): array { + $cacheKey = $this->getCacheKey( func_get_args(), "project_extensions" ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } $res = json_decode( $this->guzzle->request( 'GET', $project->getApiUrl(), [ 'query' => [ 'action' => 'query', 'meta' => 'siteinfo', @@ -359,9 +367,37 @@ public function getInstalledExtensions( Project $project ): array { ] ] )->getBody()->getContents(), true ); $extensions = $res['query']['extensions'] ?? []; - return array_map( static function ( $extension ) { + // Cache for one hour and return. + return $this->setCache( $cacheKey, array_map( static function ( $extension ) { return $extension['name']; - }, $extensions ); + }, $extensions ), 'PT1H' ); + } + + /** + * Get the list of the names for each ProofreadPage quality + * on this wiki. Keys are 0, 1, 2, 3, and 4. + * @param Project $project + * @return string[] + */ + public function getPrpQualityNames( Project $project ): array { + $cacheKey = $this->getCacheKey( func_get_args(), "project_prp_levels" ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $res = json_decode( $this->guzzle->request( 'GET', $project->getApiUrl(), [ 'query' => [ + 'action' => 'query', + 'meta' => 'proofreadinfo', + 'prpiprop' => 'qualitylevels', + 'format' => 'json', + ] ] )->getBody()->getContents(), true ); + + $qualityLevels = $res['query']['proofreadqualitylevels'] ?? []; + + // Cache for one week (will change extremely rarely) and return. + return $this->setCache( $cacheKey, array_map( static function ( $level ) { + return $level['category']; + }, $qualityLevels ), 'P1W' ); } /** diff --git a/src/Repository/TopEditsRepository.php b/src/Repository/TopEditsRepository.php index 65aa35c48..05147dfed 100644 --- a/src/Repository/TopEditsRepository.php +++ b/src/Repository/TopEditsRepository.php @@ -59,6 +59,61 @@ public function getEdit( Page $page, array $revision ): Edit { return new Edit( $this->editRepo, $this->userRepo, $page, $revision ); } + /** + * Get the selects for PageAssessments class and projects. + * @param Project $project + * @param int|string $namespace + * @return string + */ + private function getPaSelects( + Project $project, + $namespace + ): string { + $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); + $paTable = $project->getTableName( 'page_assessments' ); + $paSelect = $hasPageAssessments + ? ", ( + SELECT pa_class + FROM $paTable + WHERE pa_page_id = page_id + AND pa_class != '' + LIMIT 1 + ) AS pa_class" + : ''; + $paProjectsTable = $project->getTableName( 'page_assessments_projects' ); + $paProjectsSelect = $hasPageAssessments + ? ", ( + SELECT JSON_ARRAYAGG(pap_project_title) + FROM $paTable + JOIN $paProjectsTable + ON pa_project_id = pap_project_id + WHERE pa_page_id = page_id + ) AS pap_project_title" + : ''; + return $paSelect . $paProjectsSelect; + } + + /** + * Get the select and join for ProofreadPage quality level. + * @param Project $project + * @param int|string $namespace Namespade ID or 'all' + * @return array With keys 'prpSelect' and 'prpJoin' + */ + private function getPrpConditions( + Project $project, + $namespace + ): array { + if ( $project->isPrpPage( $namespace ) ) { + $pagePropsTable = $project->getTableName( 'page_props', '' ); + return [ + 'prpSelect' => ", pp_value as `prp_quality`", + 'prpJoin' => "LEFT OUTER JOIN $pagePropsTable + ON (pp_page, pp_propname) = (page_id, 'proofread_page_quality_level')", + ]; + } + return [ 'prpSelect' => '', 'prpJoin' => '' ]; + } + /** * Get the top edits by a user in a single namespace. * @param Project $project @@ -89,18 +144,6 @@ public function getTopEditsNamespace( $pageTable = $project->getTableName( 'page' ); $revisionTable = $project->getTableName( 'revision' ); - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); - $paTable = $project->getTableName( 'page_assessments' ); - $paSelect = $hasPageAssessments - ? ", ( - SELECT pa_class - FROM $paTable - WHERE pa_page_id = page_id - AND pa_class != '' - LIMIT 1 - ) AS pa_class" - : ''; - $ipcJoin = ''; $whereClause = 'rev_actor = :actorId'; $params = []; @@ -111,13 +154,18 @@ public function getTopEditsNamespace( [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); } + $paSelects = $this->getPaSelects( $project, $namespace ); + + $prpConditions = $this->getPrpConditions( $project, $namespace ); + $offset = $pagination * $limit; $sql = "SELECT page_namespace AS `namespace`, page_title, page_is_redirect AS `redirect`, COUNT(page_title) AS `count` - $paSelect + $paSelects + " . $prpConditions['prpSelect'] . " FROM $pageTable - JOIN $revisionTable ON page_id = rev_page + " . $prpConditions['prpJoin'] . " $ipcJoin WHERE $whereClause AND page_namespace = :namespace @@ -271,17 +319,6 @@ public function getTopEditsAllNamespaces( $revDateConditions = $this->getDateConditions( $start, $end ); $pageTable = $this->getTableName( $project->getDatabaseName(), 'page' ); $revisionTable = $this->getTableName( $project->getDatabaseName(), 'revision' ); - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments(); - $pageAssessmentsTable = $this->getTableName( $project->getDatabaseName(), 'page_assessments' ); - $paSelect = $hasPageAssessments - ? ", ( - SELECT pa_class - FROM $pageAssessmentsTable - WHERE pa_page_id = e.page_id - AND pa_class != '' - LIMIT 1 - ) AS pa_class" - : ''; $ipcJoin = ''; $whereClause = 'rev_actor = :actorId'; @@ -293,28 +330,33 @@ public function getTopEditsAllNamespaces( [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); } - $sql = "SELECT c.page_namespace AS `namespace`, e.page_title, - c.page_is_redirect AS `redirect`, c.count $paSelect - FROM - ( - SELECT b.page_namespace, b.page_is_redirect, b.rev_page, b.count - ,@rn := if(@ns = b.page_namespace, @rn + 1, 1) AS `row_number` - ,@ns := b.page_namespace AS dummy - FROM - ( - SELECT page_namespace, page_is_redirect, rev_page, count(rev_page) AS count - FROM $revisionTable - $ipcJoin - JOIN $pageTable ON page_id = rev_page - WHERE $whereClause - $revDateConditions - GROUP BY page_namespace, rev_page - ) AS b - JOIN (SELECT @ns := NULL, @rn := 0) AS vars - ORDER BY b.page_namespace ASC, b.count DESC - ) AS c - JOIN $pageTable e ON e.page_id = c.rev_page - WHERE c.`row_number` <= $limit"; + $paSelects = $this->getPaSelects( $project, 'all' ); + $prpConditions = $this->getPrpConditions( $project, 'all' ); + + $sql = " + SELECT * FROM ( + SELECT + page_namespace as `namespace`, + page_title, + page_is_redirect as `redirect`, + rev_page, + count(rev_page) AS `count`, + ROW_NUMBER() OVER ( + PARTITION BY page_namespace + ORDER BY page_namespace ASC, `count` DESC + ) `row_number` + $paSelects + " . $prpConditions['prpSelect'] . " + FROM $revisionTable + $ipcJoin + JOIN $pageTable ON page_id = rev_page + " . $prpConditions['prpJoin'] . " + WHERE $whereClause + $revDateConditions + GROUP BY page_namespace, rev_page + ) a + WHERE `row_number` <= $limit + ORDER BY `namespace` ASC, `count` DESC"; $resultQuery = $this->executeQuery( $sql, $project, $user, 'all', $params ); $result = $resultQuery->fetchAllAssociative(); diff --git a/templates/editCounter/general_stats.html.twig b/templates/editCounter/general_stats.html.twig index 19de77b62..92f587224 100644 --- a/templates/editCounter/general_stats.html.twig +++ b/templates/editCounter/general_stats.html.twig @@ -182,20 +182,20 @@