User:DannyS712/VueNPP.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
Documentation for this user script can be added at User:DannyS712/VueNPP. |
// <nowiki>
// Script to experiment with a Vue version of Special:NewPagesFeed
// @author DannyS712
/* jshint maxerr: 999, esversion: 9, esnext: false */
$(() => {
const VueNPP = {};
window.VueNPP = VueNPP;
VueNPP.init = function () {
mw.loader.using(
[
'vue',
'@vue/composition-api',
'wvui',
'mediawiki.util',
'mediawiki.api',
'moment',
// So that messages and styles are loaded
'ext.pageTriage.views.list'
],
VueNPP.run
);
};
VueNPP.run = function () {
const VueCompositionAPI = mw.loader.require( '@vue/composition-api' );
// Exposed globally for simplicity
window.VueCompositionAPI = VueCompositionAPI;
Vue.use( VueCompositionAPI );
const wvuiComponents = mw.loader.require( 'wvui' );
VueNPP.listItemComponent.components = wvuiComponents;
VueNPP.loadMoreBarComponent.components = wvuiComponents;
// Object.assign for the components that have other non-wvui components
Object.assign( VueNPP.feedControlMenuComponent.components, wvuiComponents );
Object.assign( VueNPP.feedContentsComponent.components, wvuiComponents );
VueNPP.addStyle();
VueNPP.renderInterface();
};
/**
* Add styles for our interface.
*/
VueNPP.addStyle = function () {
mw.util.addCSS(`
.mwe-vue-pt-metadata-warning:before {
color: initial;
content: " · ";
}
.mwe-vue-pt-button-green {
/* From core and vector styles for green ui buttons */
border-color: #294 !important;
background: #295 !important;
background: linear-gradient( to bottom, #3c8 0%, #295 90%) !important;
border-radius: 4px;
box-shadow: 0 1px 3px;
}
.mwe-vue-pt-button-green:disabled {
opacity: .35;
}
.mwe-vue-pt-navigation-bar {
border: 1px solid #ccc;
}
.mwe-vue-pt-control-gradient {
background: #c9c9c9;
}
#mwe-vue-pt-menu-heading {
padding: 0.5em 1em 1em 1em;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 7px 10px rgba( 0, 0, 0, 0.4 );
}
.mwe-vue-pt-control-section {
min-width: 200px;
margin: 0.4em 0.4em 0 0.4em;
z-index: 51;
}
.mwe-vue-pt-control-label-right,
#mwe-vue-pt-refresh-button {
float: right;
}
.mwe-vue-pt-control-options {
margin-left: 1em;
margin-right: 0.5em;
white-space: nowrap;
}
.mwe-vue-pt-control-buttons {
margin: 0.2em 0 0 -0.4em;
}
#mwe-vue-pt-control-dropdown {
position: absolute;
z-index: 50;
border: 1px solid #aaa;
padding: 0.5em 1em 0.2em 1em;
margin-left: 48px;
color: #000;
cursor: default;
box-shadow: 0 7px 10px rgba( 0, 0, 0, 0.4 );
width: min-content;
}
.mwe-vue-pt-control-section__row1 {
display: flex;
flex-direction: row;
}
#mwe-vue-pt-filter-user {
width: 100px;
}
#mwe-vue-pt-sort-buttons {
margin-right: 0.3em;
}
#mwe-vue-pt-radio-afc {
margin-left: 10px;
}
#mwe-vue-pt-control-menu-toggle {
color: #0645ad;
cursor: pointer;
}
#mwe-vue-pt-feed-load-more {
text-align: center;
font-size: 17px;
background-color: #e8f2f8;
margin: 0;
padding: 0.4em;
border: 1px solid #ccc;
border-top: 0;
}
#mwe-vue-pt-feed-load-more .wvui-progress-bar {
margin: auto;
/* Override WVUI styles to make a quieter version */
background-color: inherit;
border: none;
}
.mwe-vue-pt-article-row-even {
background-color: #f1f1f1;
}
.mwe-vue-pt-article-row-odd {
background-color: #fff;
}
.mwe-vue-pt-info-pane {
padding: 0.5em 0.6em 0.6em 2.7em;
min-height: 4.8em;
display: table;
box-sizing: border-box;
width: 100%;
}
.mwe-vue-pt-info-row {
display: table-row;
vertical-align: top;
}
.mwe-vue-pt-info-row > div {
display: table-cell;
}
.mwe-vue-pt-status-icon {
position: absolute;
top: 5px;
left: 5px;
}
.mwe-vue-pt-article-row {
position: relative;
border: 1px solid #ccc;
border-top: 0;
}
/* info about the article */
.mwe-vue-pt-article {
font-size: 1.1em;
line-height: 1.6em;
}
.mwe-vue-pt-bold {
font-weight: bold;
}
.mwe-vue-pt-metadata-warning,
.mwe-vue-pt-issue {
color: #c00;
font-weight: bold;
}
/* Info on the right hand side: creation date, updated date, potential isues, etc. */
.mwe-vue-pt-article-col-right {
text-align: right;
white-space: nowrap;
}
/* the article snippet */
.mwe-vue-pt-snippet {
color: #808080;
padding-right: 1em;
vertical-align: top;
}
/* Navigation bar at the bottom */
#mwe-vue-pt-stats-navigation {
min-height: 50px;
border-top: 1px solid #ccc;
position: sticky;
bottom: 0;
z-index: 1;
box-shadow: 0 -7px 10px rgba( 0, 0, 0, 0.4 );
}
#mwe-vue-pt-stats-navigation-content {
padding: 0.5em 1em;
}
`);
};
//const VueNPP = {};
//#region devCode
/**
* Component for rendering a list item.
*/
VueNPP.listItemComponent = {
// wvuiButton is added later, once it has been loaded
// defaults for props are from [[FDP Hamburg]] for now
props: {
position: { type: Number, default: 1 },
afdStatus: { type: Boolean, default: false },
blpProdStatus: { type: Boolean, default: false },
csdStatus: { type: Boolean, default: false },
prodStatus: { type: Boolean, default: false },
patrolStatus: { type: Number, default: 0 },
title: { type: String, default: 'FDP Hamburg' },
isRedirect: { type: Boolean, default: false },
categoryCount: { type: Number, default: 3 },
linkCount: { type: Number, default: 0 },
referenceCount: { type: Number, default: 1 },
recreated: { type: Boolean, default: false },
pageLen: { type: Number, default: 3760 },
revCount: { type: Number, default: 2 },
creationDateUTC: { type: String, default: '20220523131514' },
creatorName: { type: String, default: 'Wanquanbiantai' },
creatorAutoConfirmed: { type: Boolean, default: true },
creatorRegistrationUTC: { type: String, default: '20220317205608' },
creatorUserId: { type: Number, default: 43566998 },
creatorEditCount: { type: Number, default: 66 },
creatorIsBot: { type: Boolean, default: false },
creatorBlocked: { type: Boolean, default: false },
creatorUserPageExists: { type: Boolean, default: true },
creatorTalkPageExists: { type: Boolean, default: true },
afcState: { type: Number, default: 1 },
reviewedUpdatedUTC: { type: String, default: '20220523131514' },
snippet: { type: String, default: 'Chair Logo Michael Kruse FDP LV Hamburg Basis data Established: September 20, 1945 Place of establishment: Hamburg Chairman: Michael Kruse Vice chairmen: Katarina BlumeRia SchröderAndreas MoringSonja Jacobsen Treasurer: Ron Schumacher Executive direct...' },
oresArticleQuality: { type: String, default: 'Start' },
oresDraftQuality: { type: String, default: '' },
copyvio: { type: Number, default: 0 },
},
data: function () {
return {
showOres: true || mw.config.get( 'wgShowOresFilters' ),
showCopyvio: true || mw.config.get( 'wgShowCopyvio' ),
enableReviewButton: true || mw.config.get( 'wgPageTriageEnableReviewButton' ),
draftNamespaceId: mw.config.get( 'wgPageTriageDraftNamespaceId' ),
timeOffset: parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] )
};
},
methods: {
prettyTimestamp: function ( utcTimestamp ) {
const parsedTimestamp = moment.utc( utcTimestamp, 'YYYYMMDDHHmmss' );
return parsedTimestamp.utcOffset( this.timeOffset ).format(
mw.msg( 'pagetriage-creation-dateformat' )
);
},
getjQueryLink: function ( url, text, exists ) {
// Needed to be able to embed links in the byline
const $link = $( '<a>' );
if ( !exists ) {
const uri = new mw.Uri( url );
uri.query.action = 'edit';
uri.query.redlink = 1;
url = uri.toString();
$link.addClass( 'new' );
}
$link.attr( 'href', url );
$link.text( text );
return $link;
}
},
computed: {
oddEvenClass: function () { return this.position % 2 == 0 ? 'mwe-vue-pt-article-row-even' : 'mwe-vue-pt-article-row-odd'; },
isDraft: function () {
const pageNamespaceId = ( new mw.Title( this.title ) ).getNamespaceId();
return pageNamespaceId === this.draftNamespaceId;
},
iconImageSrc: function () {
const imageBase = mw.config.get( 'wgExtensionAssetsPath' ) + '/PageTriage/modules/ext.pageTriage.views.list/images/';
if ( this.isDraft ) {
return imageBase + 'icon_not_reviewed.png';
} else if ( this.afdStatus || this.blpProdStatus || this.csdStatus || this.prodStatus ) {
return imageBase + 'icon_marked_for_deletion.png';
} else if ( this.patrolStatus !== 0 ) {
return imageBase + 'icon_reviewed.png';
} else {
return imageBase + 'icon_not_reviewed.png';
}
},
titleUrl: function () {
const params = {};
if ( this.isRedirect ) {
params.redirect = 'no';
}
return mw.util.getUrl( this.title, params );
},
titleUrlFormat: function () { return mw.util.wikiUrlencode( this.title ); },
historyUrl: function () { return mw.config.get('wgScriptPath') + '/index.php?title=' + this.titleUrlFormat + '&action=history'; },
creationDatePretty: function () {
return this.prettyTimestamp( this.creationDateUTC );
},
creatorBylineHtml: function () {
const bylineMessage = ( this.creatorUserId > 0 && !this.creatorAutoConfirmed )
? 'pagetriage-byline-new-editor'
: 'pagetriage-byline';
const creatorUserPageUrl = mw.util.getUrl( 'User:' + this.creatorName );
const creatorTalkPageUrl = mw.util.getUrl( 'User talk:' + this.creatorName );
const contribsUrl = mw.util.getUrl( 'Special:Contributions/' + this.creatorName );
return mw.message(
bylineMessage,
this.getjQueryLink(
creatorUserPageUrl,
this.creatorName,
this.creatorUserPageExists
),
this.getjQueryLink(
creatorTalkPageUrl,
mw.msg( 'sp-contributions-talk' ),
this.creatorTalkPageExists
),
mw.msg( 'pipe-separator' ),
this.getjQueryLink(
contribsUrl,
mw.msg( 'contribslink' ),
true
)
).parse();
},
creatorRegistrationPretty: function () {
return this.prettyTimestamp( this.creatorRegistrationUTC );
},
reviewedUpdatedPretty: function () {
return this.prettyTimestamp( this.reviewedUpdatedUTC );
},
lastAfcActionLabel: function () {
if ( this.afcState === 2 ) {
return 'pagetriage-afc-date-label-submission';
} else if ( this.afcState === 3 ) {
return 'pagetriage-afc-date-label-review';
} else if ( this.afcState === 4 ) {
return 'pagetriage-afc-date-label-declined';
}
return '';
},
reviewRightHelpText: function () {
if ( this.enableReviewButton ) {
return '';
}
return this.$i18n( 'pagetriage-no-patrol-right' );
},
copyvioLink: function () {
if ( this.copyvio === 0 ) {
// Shouldn't be used
return '';
}
return 'https://tools.wmflabs.org/copypatrol/en?filter=all&searchCriteria=page_exact'
+ '&searchText=' + ( new mw.Title( this.title ) ).getMainText()
+ '&drafts=' + ( this.isDraft ? '1' : '0' )
+ '&revision=' + this.copyvio;
}
},
template: `<div class="mwe-vue-pt-article-row" :class="oddEvenClass">
<div class="mwe-vue-pt-status-icon">
<img :src="iconImageSrc" width="21" height="21" />
</div>
<div class="mwe-vue-pt-info-pane">
<div class="mwe-vue-pt-info-row">
<div class="mwe-vue-pt-article">
<span class="mwe-vue-pt-bold"><a :href="titleUrl" target="_blank">{{ title }}</a></span>
<span>
(<a :href="historyUrl">{{ $i18n( "pagetriage-hist" ) }}</a>)
</span>
<span>
·
{{ $i18n( "pagetriage-bytes", pageLen ) }}
·
{{ $i18n( "pagetriage-edits", revCount ) }}
<span v-if="!isDraft">
<span v-if="categoryCount === 0 && !isRedirect" class="mwe-vue-pt-metadata-warning">{{ $i18n( "pagetriage-no-categories" ) }}</span>
<template v-if="categoryCount !== 0">
· {{ $i18n( "pagetriage-categories", categoryCount ) }}
</template>
<span v-if="linkCount === 0 && !isRedirect" class="mwe-vue-pt-metadata-warning">{{ $i18n("pagetriage-orphan") }}</span>
<span v-if="recreated" class="mwe-vue-pt-metadata-warning">{{ $i18n("pagetriage-recreated") }}</span>
</span>
<span v-if="referenceCount === 0 && !isRedirect" class="mwe-vue-pt-metadata-warning">{{ $i18n( "pagetriage-no-reference" ) }}</span>
</span>
</div>
<div class="mwe-vue-pt-article-col-right mwe-vue-pt-bold">{{ creationDatePretty }}</div>
</div>
<div class="mwe-vue-pt-info-row">
<div>
<span v-if="creatorName">
<!-- Using v-html because the messages used embed links within them -->
<span v-if="creatorBylineHtml" v-html="creatorBylineHtml"></span>
<span v-if="creatorUserId > 0">
·
{{ $i18n( 'pagetriage-editcount', creatorEditCount, creatorRegistrationPretty ) }}
<span v-if="creatorIsBot">
·
{{ $i18n( 'pagetriage-author-bot' ) }}
</span>
</span>
<span v-if="creatorBlocked" class="mwe-vue-pt-metadata-warning">{{ $i18n( 'pagetriage-author-blocked' ) }}</span>
</span>
<span v-else>
{{ $i18n('pagetriage-no-author') }}
</span>
</div>
<div class="mwe-vue-pt-article-col-right">
<span v-if="lastAfcActionLabel">
<span>{{ $i18n( lastAfcActionLabel ) }}</span>
<span>{{ reviewedUpdatedPretty }}</span>
</span>
</div>
</div>
<div class="mwe-vue-pt-info-row">
<div class="mwe-vue-pt-snippet">{{ snippet }}</div>
<div class="mwe-vue-pt-article-col-right">
<a :href="titleUrl" target="_blank" :title="reviewRightHelpText">
<wvui-button action="progressive" type="primary">Review</wvui-button>
</a>
</div>
</div>
<div v-if="showOres" class="mwe-vue-pt-info-row">
<div>
<span>{{ $i18n( 'pagetriage-filter-predicted-class-heading' ) }}</span>
<span>{{ oresArticleQuality }}</span>
</div>
<div class="mwe-vue-pt-article-col-right">
<span>{{ $i18n( 'pagetriage-filter-predicted-issues-heading' ) }}</span>
<span v-if="!oresDraftQuality && !( copyvio && showCopyvio )">
{{ $i18n( 'pagetriage-filter-stat-predicted-issues-none' ) }}
</span>
<span v-if="oresDraftQuality" class="mwe-vue-pt-issue">{{ oresDraftQuality }}</span>
<span v-if="copyvio && showCopyvio">
<span v-if="oresDraftQuality">·</span>
<span class="mw-parser-output mwe-vue-pt-issue">
<a :href="copyvioLink" target="_blank" class="external">
{{ $i18n( 'pagetriage-filter-stat-predicted-issues-copyvio' ) }}
</a>
</span>
</span>
</div>
</div>
</div>
</div>`
};
/**
* Helper for controls form, contains a specific section with a message label
* and slot content
*/
VueNPP.controlSectionComponent = {
props: { label: { type: String, required: true } },
template: `
<div class="mwe-vue-pt-control-section">
<span class="mwe-vue-pt-control-label"><b>{{ $i18n( label ) }}</b></span>
<div class="mwe-vue-pt-control-options">
<slot></slot>
</div>
</div>`
};
/**
* Helper for controls form, contains controls for date ranges
*/
VueNPP.dateControlSectionComponent = {
props: {
type: { type: String, required: true },
fromModel: { type: String, required: true },
toModel: { type: String, required: true }
},
components: { controlSection: VueNPP.controlSectionComponent },
methods: {
updateFrom: function ( newValue ) {
this.$emit( 'update:fromModel', newValue.target.value );
},
updateTo: function ( newValue ) {
this.$emit( 'update:toModel', newValue.target.value );
}
},
template: `
<control-section label="pagetriage-filter-date-range-heading">
<label :for="'mwe-vue-pt-filter-' + type + '-date-range-from'">{{ $i18n( 'pagetriage-filter-date-range-from' ) }}</label>
<input type="date" :value="fromModel" @input="updateFrom" :id="'mwe-vue-pt-filter-' + type + '-date-range-from'" :placeholder="$i18n( 'pagetriage-filter-date-range-format-placeholder' )" /> <br/>
<label :for="'mwe-vue-pt-filter-' + type + '-date-range-to'">{{ $i18n( 'pagetriage-filter-date-range-to' ) }}</label>
<input type="date" :value="toModel" @input="updateTo" :id="'mwe-vue-pt-filter-' + type + '-date-range-to'" :placeholder="$i18n( 'pagetriage-filter-date-range-format-placeholder' )" />
</control-section>`
};
VueNPP.lastGeneratedIdNum = 0;
VueNPP.labeledInputComponent = {
// For some reason things break if I try to just use v-model, though v-model:model-value
// works (in the places where this component is used), but since it needs to be named
// anyway its called input-model to make it clear that its used for the <input>
props: {
// id is used to associated input with the <label> via `for`, if not
// provided auto generate one
inputId: {
type: String,
default: () => `mwe-vue-pt-generated-${++VueNPP.lastGeneratedIdNum}`
},
inputModel: { type: [ String, Boolean ], required: true },
labelMsg: { type: String, required: true },
type: { type: String, required: true },
// only needed for radios, not checkboxes
value: { type: String, default: '' },
noBreak: { type: Boolean, default: false }
},
emits: [ 'update:inputModel' ],
setup( props, { emit } ) {
const isChecked = VueCompositionAPI.computed( () => {
if ( props.type === 'radio' ) {
return ( props.inputModel === props.value );
} else if ( props.type === 'checkbox' ) {
return ( props.inputModel === true );
} else {
return false;
}
} );
const onChange = function ( event ) {
const newValue = ( props.type === 'radio' ? event.target.value : event.target.checked );
emit( 'update:inputModel', newValue );
};
const haveBreak = VueCompositionAPI.computed( () => !props.noBreak );
return { isChecked, onChange, haveBreak };
},
template: `
<input :type="type" :id="inputId" :value="value" :checked="isChecked" @change="onChange" />
<label :for="inputId">{{ $i18n( labelMsg ) }}</label> <br v-if="haveBreak" />
`
};
/**
* Convert afc submission state name to api value
* PageTriage extension uses literal 'all' with breaks things, use `false` so
* that mw.Api() filters it out, T304574
*/
VueNPP.getAfcStateForApi = function ( stateName ) {
const submissionNumbers = [ '~invalid~', 'unsubmitted', 'pending', 'reviewing', 'declined' ];
const stateIndex = submissionNumbers.indexOf( stateName );
return ( stateIndex <= 0 ? false : stateIndex.toString() );
};
/**
* Menu for controlling the filters for the pages feed
*/
VueNPP.feedControlMenuComponent = {
components: {
controlSection: VueNPP.controlSectionComponent,
dateControlSection: VueNPP.dateControlSectionComponent,
labeledInput: VueNPP.labeledInputComponent
},
props: {
currentlyShowingText: { type: String, default: 'currentlyShowingText-value' },
currentFilteredCount: { type: Number, default: -1 },
// Some form elements, sorting direction and which view we are in, trigger
// updates to the feed immediately, others need to be submitted. Regardless,
// we initialize the references with the current prop value, and then either
// when the property changes or the menu is submitted, we emit the overall
// updated object
startOptions: {
type: Object,
default: () => ( {
currentView: 'npp',
nppSortDir: 'newestfirst',
nppNamespace: 0,
nppIncludeUnreviewed: true,
nppIncludeReviewed: true,
nppIncludeNominated: true,
nppIncludeRedirects: false,
nppIncludeOthers: true,
nppFilter: 'all',
nppFilterUser: '',
nppPredictedRating: {
stub: false,
start: false,
c: false,
b: false,
good: false,
featured: false
},
nppPossibleIssues: {
vandalism: false,
spam: false,
attack: false,
copyvio: false,
none: false
},
nppDateFrom: '',
nppDateTo: '',
afcSortDir: 'newestfirst',
afcSubmissionState: 'all',
afcPredictedRating: {
stub: false,
start: false,
c: false,
b: false,
good: false,
featured: false
},
afcPossibleIssues: {
vandalism: false,
spam: false,
attack: false,
copyvio: false,
none: false
},
afcDateFrom: '',
afcDateTo: '',
} )
}
},
data: function () {
return {
haveDraftNamespace: true || !!mw.config.get( 'wgPageTriageDraftNamespaceId' ),
showOresFilters: true || mw.config.get( 'wgShowOresFilters' ),
showCopyvio: true || mw.config.get( 'wgShowCopyvio' ),
// pure data, not needed in setup()
nppFilters: [
'no-categories',
'unreferenced',
'orphan',
'recreated',
'non-autoconfirmed',
'learners',
'blocked',
'bot-edits'
// user specific filter, and then show all, handled individually
],
afcSubmissionStates: [
'unsubmitted',
'pending',
'reviewing',
'declined',
'all'
]
};
},
setup( props, { emit } ) {
// Shortcuts
const ref = VueCompositionAPI.ref;
const computed = VueCompositionAPI.computed;
const watch = VueCompositionAPI.watch;
//#region startValues
const currentView = ref( props.startOptions.currentView );
const nppSortDir = ref( props.startOptions.nppSortDir );
const nppNamespace = ref( props.startOptions.nppNamespace );
const nppIncludeUnreviewed = ref( props.startOptions.nppIncludeUnreviewed );
const nppIncludeReviewed = ref( props.startOptions.nppIncludeReviewed );
const nppIncludeNominated = ref( props.startOptions.nppIncludeNominated );
const nppIncludeRedirects = ref( props.startOptions.nppIncludeRedirects );
const nppIncludeOthers = ref( props.startOptions.nppIncludeOthers );
const nppFilter = ref( props.startOptions.nppFilter );
const nppFilterUser = ref( props.startOptions.nppFilterUser );
const nppPredictedRating = ref( { ...props.startOptions.nppPredictedRating } );
const nppPossibleIssues = ref( { ...props.startOptions.nppPossibleIssues } );
const nppDateFrom = ref( props.startOptions.nppDateFrom );
const nppDateTo = ref( props.startOptions.nppDateTo );
const afcSortDir = ref( props.startOptions.afcSortDir );
const afcSubmissionState = ref( props.startOptions.afcSubmissionState );
const afcPredictedRating = ref( { ...props.startOptions.afcPredictedRating } );
const afcPossibleIssues = ref( { ...props.startOptions.afcPossibleIssues } );
const afcDateFrom = ref( props.startOptions.afcDateFrom );
const afcDateTo = ref( props.startOptions.afcDateTo );
//#endregion
// if the submitted/declined sort options should be included, the end of
// the message key to use (pagetriage-afc-(old|new)est-*), or false to
// not include as options
const afcSortUpdated = computed( () => {
if ( afcSubmissionState.value === 'declined' ) {
return 'declined';
} else if (
afcSubmissionState.value === 'pending'
|| afcSubmissionState.value === 'reviewing'
) {
return 'submitted';
}
return false;
} );
// Make sure that afcSortDir isn't invalid
watch(
afcSubmissionState,
( newState ) => {
if ( newState !== 'unsubmitted' && newState !== 'all' ) {
// oldest/newest submitted/declined are valid
return;
}
if ( afcSortDir.value === 'newestreview' ) {
afcSortDir.value = 'newestfirst';
} else if ( afcSortDir.value === 'oldestreview' ) {
afcSortDir.value = 'oldestfirst';
}
}
);
// Need to include at least one of reviewed/unreviewed, and at least
// one of nominated for deletion/redirects/normal articles
const canSaveSettings = computed( () => {
return (
( nppIncludeUnreviewed.value || nppIncludeReviewed.value )
&& (
nppIncludeNominated.value
|| nppIncludeRedirects.value
|| nppIncludeOthers.value
)
);
} );
// Whether the control menu is even shown at all
const controlMenuOpen = ref( false );
const doSaveSettings = function () {
// need to convert the objects to raw (ores filters)
const toRaw = VueCompositionAPI.toRaw;
const settings = {
currentView: currentView.value,
nppSortDir: nppSortDir.value,
nppNamespace: nppNamespace.value,
nppIncludeUnreviewed: nppIncludeUnreviewed.value,
nppIncludeReviewed: nppIncludeReviewed.value,
nppIncludeNominated: nppIncludeNominated.value,
nppIncludeRedirects: nppIncludeRedirects.value,
nppIncludeOthers: nppIncludeOthers.value,
nppFilter: nppFilter.value,
nppFilterUser: nppFilterUser.value,
nppPredictedRating: toRaw( nppPredictedRating.value ),
nppPossibleIssues: toRaw( nppPossibleIssues.value ),
nppDateFrom: nppDateFrom.value,
nppDateTo: nppDateTo.value,
afcSortDir: afcSortDir.value,
afcSubmissionState: afcSubmissionState.value,
afcPredictedRating: toRaw( afcPredictedRating.value ),
afcPossibleIssues: toRaw( afcPossibleIssues.value ),
afcDateFrom: afcDateFrom.value,
afcDateTo: afcDateTo.value,
};
emit( 'update-settings', settings );
// manually hide, next time that its opened the start options will
// be updated
controlMenuOpen.value = false;
};
const toggleControlMenuIndicator = computed(
() => ( controlMenuOpen.value ? '▾' : '▸' )
);
// On open, restore the start options to account for any prior changes,
// on close, restore them because the current changes are being discarded
const toggleControlMenu = () => {
// note that when closing due to an immediatelly handled change
// (view or sort direction) this method is not called, but rather
// the open status is changed manually, which is why start options
// are also restored on open
reapplyStartOptions();
controlMenuOpen.value = !controlMenuOpen.value;
};
// don't trigger watchers when these are reapplied
const currentlyInReset = ref( false );
const reapplyStartOptions = () => {
currentlyInReset.value = true;
currentView.value = props.startOptions.currentView;
nppSortDir.value = props.startOptions.nppSortDir;
nppNamespace.value = props.startOptions.nppNamespace;
nppIncludeUnreviewed.value = props.startOptions.nppIncludeUnreviewed;
nppIncludeReviewed.value = props.startOptions.nppIncludeReviewed;
nppIncludeNominated.value = props.startOptions.nppIncludeNominated;
nppIncludeRedirects.value = props.startOptions.nppIncludeRedirects;
nppIncludeOthers.value = props.startOptions.nppIncludeOthers;
nppFilter.value = props.startOptions.nppFilter;
nppFilterUser.value = props.startOptions.nppFilterUser;
nppPredictedRating.value = { ...props.startOptions.nppPredictedRating };
nppPossibleIssues.value = { ...props.startOptions.nppPossibleIssues };
nppDateFrom.value = props.startOptions.nppDateFrom;
nppDateTo.value = props.startOptions.nppDateTo;
afcSortDir.value = props.startOptions.afcSortDir;
afcSubmissionState.value = props.startOptions.afcSubmissionState;
afcPredictedRating.value = { ...props.startOptions.afcPredictedRating };
afcPossibleIssues.value = { ...props.startOptions.afcPossibleIssues };
afcDateFrom.value = props.startOptions.afcDateFrom;
afcDateTo.value = props.startOptions.afcDateTo;
currentlyInReset.value = false;
};
// When the sort dir or the view changes, we want to immediately
// update the settings to use that, ignoring any other changes made.
// Close the menu so that when it is reopened, the start options are
// reused, cancelling out the changes in the local state
const handleImmediateChange = function ( changeName, changeVal ) {
if ( currentlyInReset.value ) {
// ignore
return;
}
// Make a *deep copy* of the start options
const updatedSettings = { ...props.startOptions };
updatedSettings.nppPredictedRating = { ...props.startOptions.nppPredictedRating };
updatedSettings.nppPossibleIssues = { ...props.startOptions.nppPossibleIssues };
updatedSettings.afcPredictedRating = { ...props.startOptions.afcPredictedRating };
updatedSettings.afcPossibleIssues = { ...props.startOptions.afcPossibleIssues };
// changeName should be 'currentView', 'nppSortDir', or 'afcSortDir'
updatedSettings[ changeName ] = changeVal;
emit( 'update-settings', updatedSettings );
controlMenuOpen.value = false;
};
watch( currentView, ( newVal ) => handleImmediateChange( 'currentView', newVal ) );
watch( nppSortDir, ( newVal ) => handleImmediateChange( 'nppSortDir', newVal ) );
watch( afcSortDir, ( newVal ) => handleImmediateChange( 'afcSortDir', newVal ) );
return {
controlMenuOpen, toggleControlMenu, toggleControlMenuIndicator,
currentView,
// NPP
nppSortDir,
nppNamespace,
nppIncludeUnreviewed, nppIncludeReviewed,
nppIncludeNominated, nppIncludeRedirects, nppIncludeOthers,
nppFilter, nppFilterUser,
nppPredictedRating, nppPossibleIssues,
nppDateFrom, nppDateTo,
// AFC
afcSortDir, afcSortUpdated,
afcSubmissionState,
afcPredictedRating, afcPossibleIssues,
afcDateFrom, afcDateTo,
// settings
canSaveSettings, doSaveSettings
};
},
//#region template
template: `<div id="mwe-vue-pt-menu-heading" class="mwe-vue-pt-control-gradient">
<p v-if="haveDraftNamespace">
<labeled-input type="radio" input-id="mwe-vue-pt-radio-npp" v-model:inputModel="currentView" label-msg="pagetriage-new-page-patrol" value="npp" :no-break="true" />
<labeled-input type="radio" input-id="mwe-vue-pt-radio-afc" v-model:inputModel="currentView" label-msg="pagetriage-articles-for-creation" value="afc" :no-break="true" />
</p>
<span class="mwe-vue-pt-control-label"><b>{{ $i18n( 'pagetriage-showing' ) }}</b> {{ currentlyShowingText }}</span>
<span class="mwe-vue-pt-control-label-right" v-show="currentFilteredCount !== -1">
{{ $i18n( 'pagetriage-stats-filter-page-count', currentFilteredCount ) }}
</span>
<br/>
<span v-show="currentView === 'npp'" class="mwe-vue-pt-control-label-right">
<b>{{ $i18n( 'pagetriage-sort-by' ) }}</b>
<span id="mwe-vue-pt-sort-buttons">
<labeled-input type="radio" v-model:inputModel="nppSortDir" label-msg="pagetriage-newest" value="newestfirst" :no-break="true" />
<labeled-input type="radio" v-model:inputModel="nppSortDir" label-msg="pagetriage-oldest" value="oldestfirst" :no-break="true" />
</span>
</span>
<span v-show="currentView === 'afc'" class="mwe-vue-pt-control-label-right">
<label for="mwe-vue-pt-sort-afc"><b>{{ $i18n( 'pagetriage-sort-by' ) }}</b></label>
<select v-model="afcSortDir" id="mwe-vue-pt-sort-afc">
<option value="newestfirst">{{ $i18n( 'pagetriage-afc-newest' ) }}</option>
<option value="oldestfirst">{{ $i18n( 'pagetriage-afc-oldest' ) }}</option>
<!--
'newestreview' and 'oldestreview' are used for both newest/oldest submitted and newest/oldest declined,
PageTriage adds one or the other, we just change the label - only shown when filtering for submitted, under review, or declined
-->
<option v-if="afcSortUpdated" value="newestreview">{{ $i18n( 'pagetriage-afc-newest-' + afcSortUpdated ) }}</option>
<option v-if="afcSortUpdated" value="oldestreview">{{ $i18n( 'pagetriage-afc-oldest-' + afcSortUpdated ) }}</option>
</select>
</span>
<div id="mwe-vue-pt-control-menu-toggle">
<b @click="toggleControlMenu">Set filters {{ toggleControlMenuIndicator }}</b>
<!-- Dropdown goes within the toggle with absolute position to overlay the feed -->
<div id="mwe-vue-pt-control-dropdown" class="mwe-vue-pt-control-gradient" v-show="controlMenuOpen">
<div v-show="currentView === 'npp'">
<div class="mwe-vue-pt-control-section__row1">
<div class="mwe-vue-pt-control-section__col1">
<control-section label="pagetriage-filter-namespace-heading">
<select v-model="nppNamespace">
<option value="0">Article</option>
<option value="2">User</option>
</select>
</control-section>
<control-section label="pagetriage-filter-show-heading">
<labeled-input type="checkbox" v-model:inputModel="nppIncludeUnreviewed" label-msg="pagetriage-filter-unreviewed-edits" />
<labeled-input type="checkbox" v-model:inputModel="nppIncludeReviewed" label-msg="pagetriage-filter-reviewed-edits" />
</control-section>
<control-section label="pagetriage-filter-type-show-heading">
<labeled-input type="checkbox" v-model:inputModel="nppIncludeNominated" label-msg="pagetriage-filter-nominated-for-deletion" />
<labeled-input type="checkbox" v-model:inputModel="nppIncludeRedirects" label-msg="pagetriage-filter-redirects" />
<labeled-input type="checkbox" v-model:inputModel="nppIncludeOthers" label-msg="pagetriage-filter-others" />
</control-section>
</div>
<template v-if="showOresFilters">
<div class="mwe-vue-pt-control-section__col2">
<control-section label="pagetriage-filter-predicted-class-heading">
<labeled-input v-for="(_, ratingName) in nppPredictedRating" :key="ratingName" type="checkbox" v-model:inputModel="nppPredictedRating[ ratingName ]" :label-msg="'pagetriage-filter-predicted-class-' + ratingName" />
</control-section>
</div>
<div class="mwe-vue-pt-control-section__col3">
<control-section label="pagetriage-filter-predicted-issues-heading">
<labeled-input v-for="(_, issueName) in nppPossibleIssues" :key="issueName" type="checkbox" v-model:inputModel="nppPossibleIssues[ issueName ]" :label-msg="'pagetriage-filter-predicted-issues-' + issueName" />
</control-section>
<date-control-section type="npp" v-model:fromModel="nppDateFrom" v-model:toModel="nppDateTo"></date-control-section>
</div>
</template>
<template v-else>
<div class="mwe-vue-pt-control-section__col2">
<date-control-section type="npp" v-model:fromModel="nppDateFrom" v-model:toModel="nppDateTo"></date-control-section>
</div>
</template>
</div>
<control-section label="pagetriage-filter-second-show-heading">
<labeled-input v-for="filter in nppFilters" :key="filter" type="radio" :value="filter" v-model:inputModel="nppFilter" :label-msg="'pagetriage-filter-' + filter" />
<labeled-input type="radio" v-model:inputModel="nppFilter" label-msg="pagetriage-filter-user-heading" value="username" :no-break="true" />
<input type="text" id="mwe-vue-pt-filter-user" :placeholder="$i18n( 'pagetriage-filter-username' )" v-model="nppFilterUser"/> <br/>
<labeled-input type="radio" v-model:inputModel="nppFilter" label-msg="pagetriage-filter-all" value="all" />
</control-section>
</div>
<div v-show="currentView === 'afc'">
<div class="mwe-vue-pt-control-section__row1">
<div class="mwe-vue-pt-control-section__col1">
<control-section label="pagetriage-filter-show-heading">
<labeled-input v-for="state in afcSubmissionStates" :key="state" type="radio" :value="state" v-model:inputModel="afcSubmissionState" :label-msg="'pagetriage-afc-state-' + state" />
</control-section>
<template v-if="showOresFilters">
<date-control-section type="afc" v-model:fromModel="afcDateFrom" v-model:toModel="afcDateTo"></date-control-section>
</template>
</div>
<template v-if="showOresFilters">
<div class="mwe-vue-pt-control-section__col2">
<control-section label="pagetriage-filter-predicted-class-heading">
<labeled-input v-for="(_, ratingName) in afcPredictedRating" :key="ratingName" type="checkbox" v-model:inputModel="afcPredictedRating[ ratingName ]" :label-msg="'pagetriage-filter-predicted-class-' + ratingName" />
</control-section>
</div>
<div class="mwe-vue-pt-control-section__col3">
<control-section label="pagetriage-filter-predicted-issues-heading">
<labeled-input v-for="(_, issueName) in afcPossibleIssues" :key="issueName" type="checkbox" v-model:inputModel="afcPossibleIssues[ issueName ]" :label-msg="'pagetriage-filter-predicted-issues-' + issueName" />
</control-section>
</div>
</template>
<template v-else>
<div class="mwe-vue-pt-control-section__col2">
<date-control-section type="afc" v-model:fromModel="afcDateFrom" v-model:toModel="afcDateTo"></date-control-section>
</div>
</template>
</div>
</div>
<div class="mwe-vue-pt-control-buttons">
<wvui-button class="mwe-vue-pt-button-green" action="progressive" type="primary" :disabled="!canSaveSettings" @click="doSaveSettings">{{ $i18n( 'pagetriage-filter-set-button' ) }}</wvui-button>
</div>
</div>
</div>
</div>`
};
//#endregion
/**
* Convert the page information retrieved from the api into the properties
* that listItemComponent expects.
*/
VueNPP.listItemPropFormatter = function ( pageInfo ) {
// the `position` prop is handled by the list
const listItemProps = {};
listItemProps.afdStatus = pageInfo.afd_status === '1';
listItemProps.blpProdStatus = pageInfo.blp_prod_status === '1';
listItemProps.csdStatus = pageInfo.csd_status === '1';
listItemProps.prodStatus = pageInfo.prod_status === '1';
listItemProps.patrolStatus = parseInt( pageInfo.patrol_status );
listItemProps.title = pageInfo.title;
listItemProps.isRedirect = pageInfo.is_redirect === '1';
listItemProps.categoryCount = parseInt( pageInfo.category_count );
listItemProps.linkCount = parseInt( pageInfo.linkcount );
listItemProps.referenceCount = parseInt( pageInfo.reference );
listItemProps.recreated = !!pageInfo.recreated;
listItemProps.pageLen = parseInt( pageInfo.page_len );
listItemProps.revCount = parseInt( pageInfo.rev_count );
listItemProps.creationDateUTC = pageInfo.creation_date_utc;
listItemProps.creatorName = pageInfo.user_name;
listItemProps.creatorAutoConfirmed = pageInfo.user_autoconfirmed === '1';
listItemProps.creatorRegistrationUTC = pageInfo.user_creation_date;
listItemProps.creatorUserId = parseInt( pageInfo.user_id );
listItemProps.creatorEditCount = parseInt( pageInfo.user_editcount );
listItemProps.creatorIsBot = pageInfo.user_bot === '1';
listItemProps.creatorBlocked = pageInfo.user_block_status === '1';
listItemProps.creatorUserPageExists = pageInfo.creator_user_page_exist;
listItemProps.creatorTalkPageExists = pageInfo.creator_user_talk_page_exist;
listItemProps.afcState = parseInt( pageInfo.afc_state );
listItemProps.reviewedUpdatedUTC = pageInfo.ptrp_reviewed_updated;
listItemProps.snippet = pageInfo.snippet;
listItemProps.oresArticleQuality = pageInfo.ores_articlequality;
listItemProps.oresDraftQuality = pageInfo.ores_draftquality;
listItemProps.copyvio = pageInfo.copyvio || 0;
return listItemProps;
};
/**
* Nav bar at the bottom with statistics and a refresh button.
*/
VueNPP.statsBarComponent = {
props: {
currentView: { type: String, default: 'npp' },
apiResult: {
type: Object,
default: () => ( {} )
}
},
setup( props, { emit } ) {
const triggerRefresh = () => {
emit( 'refresh-feed' );
};
const unreviewedCount = VueCompositionAPI.computed( () => {
if ( props.apiResult.result === 'success'
&& props.apiResult.stats
&& props.apiResult.stats.unreviewedarticle
) {
return props.apiResult.stats.unreviewedarticle.count;
}
// Should not be shown
return -1;
} );
const unreviewedOldest = VueCompositionAPI.computed( () => {
if ( props.apiResult.result === 'success'
&& props.apiResult.stats
&& props.apiResult.stats.unreviewedarticle
) {
const rawOldest = props.apiResult.stats.unreviewedarticle.oldest;
// convert to number of days based on formatDaysFromNow in
// pagetriage
if ( !rawOldest ) {
return '';
}
var now = new Date();
now = new Date(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
now.getUTCHours(),
now.getUTCMinutes(),
now.getUTCSeconds()
);
var begin = moment.utc( rawOldest, 'YYYYMMDDHHmmss' );
var diff = Math.round( ( now.getTime() - begin.valueOf() ) / ( 1000 * 60 * 60 * 24 ) );
if ( diff ) {
return mw.msg( 'days', diff );
}
return mw.msg( 'pagetriage-stats-less-than-a-day', diff );
}
// Should not be shown
return '?';
} );
const reviewedCount = VueCompositionAPI.computed( () => {
if ( props.apiResult.result === 'success'
&& props.apiResult.stats
&& props.apiResult.stats.reviewedarticle
) {
return props.apiResult.stats.reviewedarticle.reviewed_count;
}
// Should not be shown
return -1;
} );
const showStats = VueCompositionAPI.computed( () => {
// make sure all the values were computed
return props.currentView === 'npp'
&& unreviewedCount.value !== -1
&& unreviewedOldest.value !== '?'
&& reviewedCount.value !== -1
} );
return {
triggerRefresh,
showStats,
unreviewedCount,
unreviewedOldest,
reviewedCount
};
},
template: `<div id="mwe-vue-pt-stats-navigation" class="mwe-vue-pt-navigation-bar mwe-vue-pt-control-gradient">
<div id="mwe-vue-pt-stats-navigation-content">
<button id="mwe-vue-pt-refresh-button" class="ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only" @click="triggerRefresh">
<span class="ui-button-text">{{ $i18n( 'pagetriage-refresh-list' ) }}</span>
</button>
<div v-show="showStats">
<div>{{ $i18n( 'pagetriage-unreviewed-article-count', unreviewedCount, unreviewedOldest ) }}</div>
<div>{{ $i18n( 'pagetriage-reviewed-article-count-past-week', reviewedCount ) }}</div>
</div>
</div>
</div>`
};
/**
* Component for the bar after the last entry that allows loading more when
* scrolled into view. Whether to show or not is based on a prop instead of
* being controlled in the calling code so that the intersection observer
* does not need to be recreated each time.
*/
VueNPP.loadMoreBarComponent = {
props: {
haveMore: { type: Boolean, required: true }
},
setup( props, { emit } ) {
const emitLoadMore = function () {
// check that we should try to load
if ( props.haveMore ) {
emit( 'trigger-load' );
}
};
const barRef = VueCompositionAPI.ref();
const observerCallback = function ( entries, observer ) {
const observerEntry = entries[0];
// whether we scrolled to see it or away from it
const nowSeen = observerEntry.isIntersecting;
if ( !nowSeen ) {
return;
}
// console.log( observerEntry );
emitLoadMore();
};
const observer = new IntersectionObserver( observerCallback );
Vue.onMounted( () => {
observer.observe( barRef.value );
} );
return {
barRef,
emitLoadMore
};
},
template: `<div v-show="haveMore" ref="barRef">
<div id="mwe-vue-pt-feed-load-more">
<wvui-progress-bar></wvui-progress-bar>
<wvui-button action="progressive" type="quiet" @click="emitLoadMore">Load more</wvui-button>
</div>
</div>`
};
/**
* Component for the overall list contents, is given the api properties to
* query with and generates the items to show.
*/
VueNPP.feedContentsComponent = {
// wvui components are added separately
components: {
listItem: VueNPP.listItemComponent,
loadMoreBar: VueNPP.loadMoreBarComponent,
statsBar: VueNPP.statsBarComponent
},
props: {
params: { type: Object, required: true }
},
data: function () {
return {
// Enable adding by specific page id for debugging
manualDebug: false
};
},
setup( props, { emit } ) {
const API_PAGE_LIMIT = 20;
const ref = VueCompositionAPI.ref;
const api = new mw.ForeignApi( '//en.wikipedia.org/w/api.php' );
const apiError = ref( false );
const feedEntries = ref( [] );
// incremented before being used
const latestPosition = ref( 0 );
// 0 is ignored; `offset` and `pageoffset` parameters
const apiOffsets = ref( { normal: 0, page: 0 } );
const haveMoreToLoad = ref( true );
const alreadyLoading = ref( false );
const onApiFailure = function ( res, shouldRender ) {
console.log( res );
if ( shouldRender ) {
apiError.value = true;
}
alreadyLoading.value = false;
};
const addPageToFeed = function ( pageInfo ) {
const propData = VueNPP.listItemPropFormatter( pageInfo );
propData.position = ( ++latestPosition.value );
feedEntries.value.push( propData );
};
const processResult = function ( res ) {
// console.log( res );
if ( !res || !res.pagetriagelist || !res.pagetriagelist.pages
|| !res.pagetriagelist.pages[0]
) {
onApiFailure( res, true );
return;
}
haveMoreToLoad.value = false;
const allPages = res.pagetriagelist.pages;
if ( allPages.length > API_PAGE_LIMIT ) {
// Have more to load
allPages.pop();
haveMoreToLoad.value = true;
}
for ( var iii = 0; iii < allPages.length; iii++ ) {
addPageToFeed( allPages[iii] );
}
// offset with the last
const lastPage = allPages[ allPages.length - 1 ];
apiOffsets.value.normal = lastPage.creation_date_utc;
apiOffsets.value.page = lastPage.pageid;
alreadyLoading.value = false;
};
const addFromApi = function ( apiParams ) {
apiParams.action = 'pagetriagelist';
apiParams.format = 'json';
apiParams.formatversion = 2;
apiParams.limit = API_PAGE_LIMIT;
apiParams.offset = apiOffsets.value.normal;
apiParams.pageoffset = apiOffsets.value.page;
// console.log( apiParams );
api.get( apiParams ).then(
( res ) => processResult( res ),
( res ) => onApiFailure( res, true )
);
};
// Default is [[FDP Hamburg]] for now (for manualDebug)
const targetPageId = ref( 70853005 );
const updatePageId = ( newPageId ) => targetPageId.value = newPageId;
const addFromPageId = function () {
addFromApi( { page_id: targetPageId.value } );
};
const loadFromFilters = function () {
if ( alreadyLoading.value === true ) {
// race condition
return;
}
alreadyLoading.value = true;
console.log( 'Loading from filters' );
// make a copy, and remove unknown param
const paramsFromProps = { ...props.params };
delete paramsFromProps.mode;
addFromApi( paramsFromProps );
};
// Passed to stats bar
const currentView = VueCompositionAPI.computed( () => {
return props.params.mode;
} );
const clearCurrentData = function () {
feedEntries.value = [];
latestPosition.value = 0;
haveMoreToLoad.value = true;
apiOffsets.value.normal = 0;
apiOffsets.value.page = 0;
};
const feedStats = ref( {} );
const processNewStats = function ( newStats ) {
// console.log( newStats );
feedStats.value = newStats.pagetriagestats;
// hack - the number of pages in the filtered list is used in a
// different component (the menu bar at the top) and its easier
// to fetch the stats here than to fetch in the parent, send the
// data up via events
emit( 'new-filtered-count', newStats.pagetriagestats.stats.filteredarticle );
};
const updateStats = function () {
// make a copy, and remove unknown params
const apiParams = { ...props.params };
delete apiParams.mode;
delete apiParams.dir;
apiParams.action = 'pagetriagestats';
apiParams.format = 'json';
apiParams.formatversion = 2;
// console.log( apiParams );
api.get( apiParams ).then(
( res ) => processNewStats( res ),
( res ) => onApiFailure( res, false )
);
}
const refreshFeed = function () {
console.log( 'Should refresh feed' );
clearCurrentData();
loadFromFilters();
updateStats();
}
VueCompositionAPI.watch(
VueCompositionAPI.toRef( props, 'params' ),
refreshFeed
);
Vue.onMounted( () => refreshFeed() );
return {
targetPageId, updatePageId,
addFromPageId,
apiError, feedEntries,
haveMoreToLoad, loadFromFilters,
refreshFeed, currentView, feedStats
};
},
template: `<div>
<div v-if="manualDebug">
Specific page entry, by page id: <wvui-input :value="targetPageId" v-on:input="updatePageId"></wvui-input>
<br>
<wvui-button action="progressive" type="primary" v-on:click="addFromPageId">Add entry</wvui-button>
<br>
</div>
<div v-show="apiError">
Api error, see console
<br>
</div>
<template v-if="feedEntries">
<list-item v-for="feedEntry in feedEntries" :key="feedEntry.position" v-bind="feedEntry"></list-item>
</template>
<load-more-bar :have-more="haveMoreToLoad" @trigger-load="loadFromFilters"></load-more-bar>
<stats-bar :current-view="currentView" :api-result="feedStats" @refresh-feed=refreshFeed></stats-bar>
</div>`
};
/**
* Interface for user to choose an article
*/
VueNPP.NPPFeedMenu = {
// wvui components are added separately
components: {
feedControlMenu: VueNPP.feedControlMenuComponent,
feedContents: VueNPP.feedContentsComponent
},
setup( props ) {
const ref = VueCompositionAPI.ref;
const computed = VueCompositionAPI.computed;
const currentSettings = ref ( {
currentView: 'npp',
nppSortDir: 'newestfirst',
nppNamespace: 0,
nppIncludeUnreviewed: true,
nppIncludeReviewed: true,
nppIncludeNominated: true,
nppIncludeRedirects: false,
nppIncludeOthers: true,
nppFilter: 'all',
nppFilterUser: '',
nppPredictedRating: {
stub: false,
start: false,
c: false,
b: false,
good: false,
featured: false
},
nppPossibleIssues: {
vandalism: false,
spam: false,
attack: false,
copyvio: false,
none: false
},
nppDateFrom: '',
nppDateTo: '',
afcSortDir: 'newestfirst',
afcSubmissionState: 'all',
afcPredictedRating: {
stub: false,
start: false,
c: false,
b: false,
good: false,
featured: false
},
afcPossibleIssues: {
vandalism: false,
spam: false,
attack: false,
copyvio: false,
none: false
},
afcDateFrom: '',
afcDateTo: '',
} );
const updateSettings = function ( newVal ) {
// deep copy
currentSettings.value = newVal;
currentSettings.value.nppPredictedRating = { ...newVal.nppPredictedRating };
currentSettings.value.nppPossibleIssues = { ...newVal.nppPossibleIssues };
currentSettings.value.afcPredictedRating = { ...newVal.afcPredictedRating };
currentSettings.value.afcPossibleIssues = { ...newVal.afcPossibleIssues };
};
const offset = parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] );
const apiOptions = computed( () => {
// shortcut
const currentSV = currentSettings.value;
// limit is added by feedContentsComponent
const params = {
mode: currentSV.currentView
};
const addIfToggled = function ( paramName, optionToggle ) {
if ( optionToggle ) {
params[ paramName ] = '1';
}
};
const addOresFilters = function ( optionsObj, paramPrefix ) {
for ( var optionName in optionsObj ) {
addIfToggled( paramPrefix + optionName, optionsObj[ optionName ] );
}
};
const addNppFilter = function () {
const filtersToParams = {
'no-categories': 'no_category',
'unreferenced': 'unreferenced',
'orphan': 'no_inbound_links',
'recreated': 'recreated',
'non-autoconfirmed': 'non_autoconfirmed_users',
'learners': 'learners',
'blocked': 'blocked_users',
'bot-edits': 'showbots'
};
const chosenFilter = currentSV.nppFilter;
if ( chosenFilter === 'username' && currentSV.nppFilterUser ) {
params.username = currentSV.nppFilterUser;
// if username is chosen with no filter, or 'all'
} else if ( filtersToParams[ chosenFilter ] !== undefined ) {
params[ filtersToParams[ chosenFilter ] ] = '1';
}
};
const addDateParams = function ( fromVal, toVal ) {
if ( fromVal ) {
const fromDate = moment.utc( fromVal ).subtract( offset, 'minutes' );
params.date_range_from = fromDate.toISOString();
}
if ( toVal ) {
let toDate = moment.utc( toVal ).subtract( offset, 'minutes' );
// move to the end of the given day
toDate.add( 1, 'day' ).subtract( 1, 'second' );
params.date_range_to = toDate.toISOString();
}
};
if ( currentSV.currentView === 'npp' ) {
addIfToggled( 'showreviewed', currentSV.nppIncludeReviewed );
addIfToggled( 'showunreviewed', currentSV.nppIncludeUnreviewed );
addIfToggled( 'showdeleted', currentSV.nppIncludeNominated );
addIfToggled( 'showredirs', currentSV.nppIncludeRedirects );
addIfToggled( 'showothers', currentSV.nppIncludeOthers );
addNppFilter();
addOresFilters( currentSV.nppPredictedRating, 'show_predicted_class_' );
addOresFilters( currentSV.nppPossibleIssues, 'show_predicted_issues_' );
params.namespace = currentSV.nppNamespace;
params.dir = currentSV.nppSortDir;
addDateParams( currentSV.nppDateFrom, currentSV.nppDateTo );
} else {
addOresFilters( currentSV.afcPredictedRating, 'show_predicted_class_' );
addOresFilters( currentSV.afcPossibleIssues, 'show_predicted_issues_' );
params.showreviewed = '1';
params.showunreviewed = '1';
params.namespace = 118 || mw.config.get( 'wgNamespaceIds' ).draft;
params.dir = currentSV.afcSortDir;
const afcSubmissionStateApi = VueNPP.getAfcStateForApi( currentSV.afcSubmissionState );
if ( afcSubmissionStateApi !== false ) {
params.afc_state = afcSubmissionStateApi;
}
addDateParams( currentSV.afcDateFrom, currentSV.afcDateTo );
}
return params;
} );
const showingText = computed( () => {
const showingMessageObj = {
namespace: [],
state: [],
type: [],
'predicted-class': [],
'predicted-issues': [],
top: [],
date_range: []
};
const addOresShowing = function ( settingsObj, msgPrefix ) {
for ( var settingsOption in settingsObj ) {
if ( settingsObj[ settingsOption ] ) {
showingMessageObj[ msgPrefix ].push(
mw.msg( `pagetriage-filter-stat-${msgPrefix}-${settingsOption}` )
);
}
}
};
const addDateShowing = function ( dateFrom, dateTo ) {
if ( dateFrom ) {
const forFormattingFrom = moment( dateFrom );
const formattedFrom = forFormattingFrom.utcOffset( offset )
.format( mw.msg( 'pagetriage-filter-date-range-format-showing' ) );
showingMessageObj.date_range.push(
mw.msg( 'pagetriage-filter-stat-date_range_from', formattedFrom )
);
}
if ( dateTo ) {
const forFormattingTo = moment( dateTo );
const formattedTo = forFormattingTo.utcOffset( offset )
.format( mw.msg( 'pagetriage-filter-date-range-format-showing' ) );
showingMessageObj.date_range.push(
mw.msg( 'pagetriage-filter-stat-date_range_to', formattedTo )
);
}
};
const addShowingIf = function ( isApplicable, msgSuffix, msgGroup ) {
if ( isApplicable ) {
showingMessageObj[ msgGroup ].push(
mw.msg( `pagetriage-filter-stat-${msgSuffix}` )
);
}
};
const currentSV = currentSettings.value;
if ( currentSV.currentView === 'npp' ) {
showingMessageObj.namespace.push(
currentSV.nppNamespace === 0 ? 'Article' : 'User'
);
const showingNPPFilter = currentSV.nppFilter;
if ( showingNPPFilter === 'username' ) {
if ( currentSV.nppFilterUser ) {
showingMessageObj.top.push(
mw.msg( 'pagetriage-filter-stat-username', currentSV.nppFilterUser )
);
}
} else if ( showingNPPFilter === 'bot-edits' ) {
// Need a different message key (not -bot-edits)
showingMessageObj.top.push( mw.msg( 'pagetriage-filter-stat-bots' ) );
} else if ( showingNPPFilter !== 'all' ) {
showingMessageObj.top.push( mw.msg( `pagetriage-filter-stat-${showingNPPFilter}` ) );
}
addShowingIf( currentSV.nppIncludeReviewed, 'reviewed', 'state' );
addShowingIf( currentSV.nppIncludeUnreviewed, 'unreviewed', 'state' );
addShowingIf( currentSV.nppIncludeNominated, 'nominated-for-deletion', 'type' );
addShowingIf( currentSV.nppIncludeRedirects, 'redirects', 'type' );
addShowingIf( currentSV.nppIncludeOthers, 'others', 'type' );
addOresShowing( currentSV.nppPredictedRating, 'predicted-class' );
addOresShowing( currentSV.nppPossibleIssues, 'predicted-issues' );
addDateShowing( currentSV.nppDateFrom, currentSV.nppDateTo );
} else {
addOresShowing( currentSV.afcPredictedRating, 'predicted-class' );
addOresShowing( currentSV.afcPossibleIssues, 'predicted-issues' );
addDateShowing( currentSV.afcDateFrom, currentSV.afcDateTo );
showingMessageObj.state.push(
mw.msg( `pagetriage-afc-state-${currentSV.afcSubmissionState}` )
);
}
return Object.keys( showingMessageObj )
.map( function ( group ) {
const groupShowing = showingMessageObj[ group ];
if ( groupShowing.length === 0 ) {
return '';
}
if ( group == 'top' || ( currentSV.currentView === 'afc' && group === 'state' ) ) {
return groupShowing[ 0 ];
}
return mw.msg( `pagetriage-filter-stat-${group}` ) + ' '
+ mw.msg( 'parentheses', groupShowing.join( mw.msg( 'comma-separator' ) ) );
} )
.filter( ( msg ) => msg !== '' )
.join( mw.msg( 'comma-separator' ) );
} );
// start as -1 until fetched the first time; fetched with the rest of
// the statistics in the nav bar within feed contents, and then
// passed up via an event to all knowing it here to pass to the menu
const currentFilteredCount = ref( -1 );
const updateFilteredCount = function ( val ) {
currentFilteredCount.value = val;
};
return {
currentSettings, updateSettings,
apiOptions, showingText,
currentFilteredCount, updateFilteredCount
};
},
template: `<div>
Settings: {{ apiOptions }}
<feed-control-menu :start-options="currentSettings" @update-settings="updateSettings" :currently-showing-text="showingText" :current-filtered-count="currentFilteredCount"></feed-control-menu>
<br>
<feed-contents :params="apiOptions" @new-filtered-count="updateFilteredCount"></feed-contents>
</div>`
};
//#endregion
//module.exports = VueNPP.NPPFeedMenu;
/**
* Render VueNPP interface
*/
VueNPP.renderInterface = function () {
Vue.createMwApp( VueNPP.NPPFeedMenu )
.mount( '#mw-content-text' );
};
});
$( document ).ready( () => {
if (
mw.config.get( 'wgPageName' ) === 'Special:BlankPage/VueNPP'
) {
window.VueNPP.init();
}
});
// </nowiki>