diff --git a/i18n/ccrf-loader/en.json b/i18n/ccrf-loader/en.json new file mode 100644 index 000000000..d80b436d5 --- /dev/null +++ b/i18n/ccrf-loader/en.json @@ -0,0 +1,3 @@ +{ + "deputy.ccrf.start": "File a new case request" +} diff --git a/i18n/ccrf/en.json b/i18n/ccrf/en.json new file mode 100644 index 000000000..750dcc3e4 --- /dev/null +++ b/i18n/ccrf/en.json @@ -0,0 +1,64 @@ +{ + "deputy.ccrf.start": "File a new case request", + + "deputy.ccrf.caseRequestFilingDialog.title": "CCI Case Request Filer", + + "deputy.ccrf.previous": "Previous", + "deputy.ccrf.next": "Next", + + "deputy.ccrf.intro.heading": "Hello!", + "deputy.ccrf.intro.newbies": "If you're new here, please read the introduction below.", + "deputy.ccrf.intro.explain1": "Welcome to the CCI Case Request Filer. This wizard will help you with spot-checking a user for possible copyright violations and, if needed, file a case request at this wiki's [[$1|contributor copyright investigations]] (CCI) board.", + "deputy.ccrf.intro.explain2": "First and foremost, however, it is important to note that the wizard itself is not able to properly identify copyright investigations, but it will try its best to help you find possible violations. When spot-checking a user, it is important that you perform a thorough check at the data provided by the wizard to ensure that you're filing an accurate request. And as always, please assume good faith when performing spot-checks.", + + "deputy.ccrf.step1.heading": "Select a user", + "deputy.ccrf.step1.details": "Type the name of the user you want to investigate in the box below. You can also paste a URL of that user's userpage, talk page, or contributions page.", + "deputy.ccrf.step1.details2": "Once you've selected a user, we'll run a few background checks for you. You'll still have to interpret these checks on your own.", + "deputy.ccrf.step1.placeholder": "Username", + "deputy.ccrf.step1.error.invalid": "It looks like this username is invalid. Check your input and try again.", + "deputy.ccrf.step1.error.notfound": "It looks like this user doesn't exist on this wiki. Check your spelling and try again.", + + "deputy.ccrf.step2.heading": "Perform background checks", + "deputy.ccrf.step2.details": "We're running a few background checks for you. Some of these checks may contain false positives and false negatives; it's important that you double check the data here before including them in your request. These background checks determine if:", + "deputy.ccrf.step2.details.page": "$1 created pages that were deleted for copyright violations", + "deputy.ccrf.step2.details.revisions": "$1 has revisions hidden due to copyright violations", + "deputy.ccrf.step2.details.revisions.note": "Note that $1's revisions may have been collateral damage from another user's copyright violation. We've placed notes on revisions where this is the likely case.", + "deputy.ccrf.step2.details.warnings": "$1 has received copyright-related warnings in the past", + "deputy.ccrf.step2.details2": "These checks may take a while. Some of these checks have progress indicators to help you gauge how long they'll take. Grab a cup of coffee or stare at [[Special:RecentChanges|recent changes]] while you wait.", + "deputy.ccrf.step2.noUser": "You haven't selected a user yet. Please go back to the previous step and select a user.", + "deputy.ccrf.step2.error": "An error occurred while attempting to perform background checks: $1", + + "deputy.ccrf.step2.fallback": "Prior to writing a case on $1, check if this user has a history of copyright violations. This can help you determine if this user is a repeat offender, and you can include this information as required in your report. You can start off by checking the following places:", + "deputy.ccrf.step2.fallback.link1": "[[Special:PageHistory/User talk:$1|$1's talk page history]] (to look for past warnings)", + "deputy.ccrf.step2.fallback.link2": "[[Special:Log/create|Page creation log]] (to look for pages by the user that have since been deleted for copyright reasons)", + "deputy.ccrf.step2.fallback2": "Once you're done, take note of what you've found to include them in the your request.", + + "deputy.ccrf.check.page.load": "Checking user's deleted pages...", + "deputy.ccrf.check.page.match": "Found $1 pages that were likely copyright violations", + "deputy.ccrf.check.page.clear": "Found no pages that were likely copyright violations", + "deputy.ccrf.check.page.none": "Deputy was unable to find any pages that were deleted for copyright violations.", + "deputy.ccrf.check.revision.load": "Checking user's hidden revisions...", + "deputy.ccrf.check.revision.match": "Found $1 revisions that may have copyright violations, none likely", + "deputy.ccrf.check.revision.match.close": "Found $1 revisions that may have copyright violations, $2 likely", + "deputy.ccrf.check.revision.clear": "Found no revisions that were deleted for copyright violations", + "deputy.ccrf.check.revision.none": "Deputy was unable to find any revisions by this user that were deleted or hidden for copyright violations.", + "deputy.ccrf.check.warnings.load": "Checking user's warnings...", + "deputy.ccrf.check.warnings.match": "Found $1 copyright violation warnings", + "deputy.ccrf.check.warnings.clear": "Found no copyright violation warnings", + "deputy.ccrf.check.toggle": "Toggle details", + + "deputy.ccrf.page.links.parenthesis": "(", + "deputy.ccrf.page.links.separator": " | ", + "deputy.ccrf.page.links.history": "history", + "deputy.ccrf.page.links.edit": "edit", + "deputy.ccrf.page.links.end": ")", + + "deputy.ccrf.page.details.separator": "•", + "deputy.ccrf.page.details.created": "created $1", + "deputy.ccrf.page.deleted": "Deleted on [[$1|$2]] by [[$3|$4]] with reason \"$5\"", + "deputy.ccrf.page.deleted.userhidden": "Deleted on [[$1|$2]] with reason \"$3\"", + + "deputy.ccrf.revision.deleted": "Deleted on [[$1|$2]] by [[$3|$4]] with reason \"$5\"", + "deputy.ccrf.revision.deleted.userhidden": "Deleted on [[$1|$2]] with reason \"$3\"", + "deputy.ccrf.revision.likely": "Likely cause of deletion" +} diff --git a/i18n/shared/en.json b/i18n/shared/en.json index 948dfc00b..7dd8a230a 100644 --- a/i18n/shared/en.json +++ b/i18n/shared/en.json @@ -10,6 +10,12 @@ "deputy.ante.short": "Attrib. Template Editor", "deputy.ante.acronym": "Deputy: ANTE", + "deputy.ccrf": "CCI Case Request Filer", + "deputy.ccrf.short": "Case Request Filer", + "deputy.ccrf.acronym": "Deputy: CCRF", + + "deputy.module.loadFailure": "The requested module couldn't be loaded ($1).", + "deputy.cancel": "Cancel", "deputy.review": "Review", "deputy.review.title": "Review a diff of the changes to be made to the page", @@ -34,6 +40,8 @@ "deputy.revision.tags": "{{PLURAL:$1|Tag|Tags}}:", "deputy.revision.new": "N", "deputy.revision.new.tooltip": "This edit created a new page.", + "deputy.revision.removed.user": "Username or IP removed", + "deputy.revision.removed.comment": "edit summary removed", "deputy.comma-separator": ", ", diff --git a/package.json b/package.json index 13d2945ce..aa4a5824d 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,20 @@ "test:clean": "shx rm -rf tests/artifacts/ && shx mkdir -p tests/artifacts/", "test:jest": "jest", "clean": "shx rm -rf build node_modules/.cache/rollup-plugin-typescript2", - "prebuild": "shx mkdir -p build/i18n/ && shx cp -Rf i18n/* build/i18n/", - "build": "npm run prebuild && rollup -c", + "index": "node scripts/genUtilIndices.js", + "prebuild": "npm run index && shx mkdir -p build/i18n/ && shx cp -Rf i18n/* build/i18n/", + "build": "rollup -c", "build:deputy": "npm run prebuild && cross-env DEPUTY_ONLY=deputy rollup -c", "build:ante": "npm run prebuild && cross-env DEPUTY_ONLY=ante rollup -c", "build:ia": "npm run prebuild && cross-env DEPUTY_ONLY=ia rollup -c", + "build:ccrf": "npm run prebuild && cross-env DEPUTY_ONLY=ccrf rollup -c", + "build:ccrf-loader": "npm run prebuild && cross-env DEPUTY_ONLY=ccrf-loader rollup -c", "dev": "cross-env DEPUTY_DEV=true rollup -c --watch", "dev:deputy": "cross-env DEPUTY_ONLY=deputy npm run dev", "dev:ante": "cross-env DEPUTY_ONLY=ante npm run dev", - "dev:ia": "cross-env DEPUTY_ONLY=ia npm run dev" + "dev:ia": "cross-env DEPUTY_ONLY=ia npm run dev", + "dev:ccrf": "cross-env DEPUTY_ONLY=ccrf npm run dev", + "dev:ccrf-loader": "cross-env DEPUTY_ONLY=ccrf,ccrf-loader npm run dev" }, "repository": { "type": "git", diff --git a/rollup.config.js b/rollup.config.js index 6c8bc0d4a..660376514 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -220,5 +220,37 @@ export default [ }, plugins: getPlugins(), watch: getWatch() + } ), + + // Standalone CCI Case Request Filer + auto( 'ccrf', { + ...globals, + input: 'src/modules/ccrf/CCICaseRequestFilerStandalone.ts', + output: { + sourcemap: true, + file: 'build/deputy-ccrf.js', + format: 'iife', + banner: loadBanner( 'src', 'modules', 'ccrf', 'BANNER.txt' ) + + '\n// ', + footer: '// \n// <3' + }, + plugins: getPlugins(), + watch: getWatch() + } ), + + // Standalone CCI Case Request Filer Loader + auto( 'ccrf-loader', { + ...globals, + input: 'src/modules/ccrf/CCICaseRequestFilerLoader.ts', + output: { + sourcemap: true, + file: 'build/deputy-ccrf-loader.js', + format: 'iife', + banner: loadBanner( 'src', 'modules', 'ccrf', 'BANNER.txt' ) + + '\n// ', + footer: '// \n// <3' + }, + plugins: getPlugins(), + watch: getWatch() } ) ].filter( ( v ) => !!v ); diff --git a/src/config/WikiConfiguration.ts b/src/config/WikiConfiguration.ts index 7af273426..e7aaa5b35 100644 --- a/src/config/WikiConfiguration.ts +++ b/src/config/WikiConfiguration.ts @@ -23,6 +23,7 @@ import applyOverrides from '../util/applyOverrides'; import log from '../util/log'; import warn from '../util/warn'; import error from '../util/error'; +import { StringFilterType } from './types'; export type WikiPageConfiguration = { title: mw.Title, @@ -266,6 +267,14 @@ export default class WikiConfiguration extends ConfigurationBase { defaultValue: collapseBottom, displayOptions: { type: 'code' } } ), + requestsHeader: new Setting( { + defaultValue: null, + displayOptions: { type: 'text' } + } ), + requestsTemplate: new Setting( { + defaultValue: null, + displayOptions: { type: 'code' } + } ), earwigRoot: new Setting( { serialize: ( v ) => v.href, deserialize: ( v ) => new URL( v ), @@ -350,8 +359,48 @@ export default class WikiConfiguration extends ConfigurationBase { } ) }; + public readonly ccrf = { + introExtra: new Setting( { + defaultValue: null, + displayOptions: { type: 'code' } + } ), + performPageChecks: new Setting( { + defaultValue: true, + displayOptions: { type: 'checkbox' } + } ), + performRevisionChecks: new Setting( { + defaultValue: true, + displayOptions: { type: 'checkbox' } + } ), + performWarningChecks: new Setting( { + defaultValue: true, + displayOptions: { type: 'checkbox' } + } ), + pageDeletionFilters: new Setting( { + ...Setting.basicSerializers, + defaultValue: null, + displayOptions: { type: 'unimplemented' } + } ), + revisionDeletionFilters: new Setting( { + ...Setting.basicSerializers, + defaultValue: null, + displayOptions: { type: 'unimplemented' } + } ), + warningFilters: new Setting( { + ...Setting.basicSerializers, + defaultValue: null, + displayOptions: { type: 'unimplemented' } + } ) + }; + readonly type = 'wiki'; - public readonly all = { core: this.core, cci: this.cci, ante: this.ante, ia: this.ia }; + public readonly all = { + core: this.core, + cci: this.cci, + ante: this.ante, + ia: this.ia, + ccrf: this.ccrf + }; /** * Set to true when this configuration is outdated based on latest data. Usually adds banners diff --git a/src/config/types.ts b/src/config/types.ts index c05be8d76..2d21def94 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -36,3 +36,5 @@ export enum PortletNameView { Short = 'short', Acronym = 'acronym' } + +export type StringFilterType = string | string[] | { source: string, flags: string }; diff --git a/src/modules/ccrf/BANNER.txt b/src/modules/ccrf/BANNER.txt new file mode 100644 index 000000000..bcf76b2c2 --- /dev/null +++ b/src/modules/ccrf/BANNER.txt @@ -0,0 +1,28 @@ + + CASE REQUEST FILER + + File CCI requests through a guided interface. + + ------------------------------------------------------------------------ + + Copyright 2022 Chlod Aidan Alejandro + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ------------------------------------------------------------------------ + + NOTE TO USERS AND DEBUGGERS: This userscript is originally written in + TypeScript. The original TypeScript code is converted to raw JavaScript + during the build process. To view the original source code, visit + + https://github.com/ChlodAlejandro/deputy diff --git a/src/modules/ccrf/BackgroundChecks.tsx b/src/modules/ccrf/BackgroundChecks.tsx new file mode 100644 index 000000000..c615eb541 --- /dev/null +++ b/src/modules/ccrf/BackgroundChecks.tsx @@ -0,0 +1,5 @@ +export interface BackgroundChecks { + page: boolean; + revision: boolean; + warnings: boolean; +} diff --git a/src/modules/ccrf/CCICaseRequestFiler.ts b/src/modules/ccrf/CCICaseRequestFiler.ts new file mode 100644 index 000000000..853df96fa --- /dev/null +++ b/src/modules/ccrf/CCICaseRequestFiler.ts @@ -0,0 +1,136 @@ +import DeputyModule from '../DeputyModule'; +import deputyCcrfEnglish from '../../../i18n/ccrf/en.json'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import iaStyles from './css/cci-case-request-filer.css'; +import DeputyLanguage from '../../DeputyLanguage'; +import deputySharedEnglish from '../../../i18n/shared/en.json'; +import CaseRequestFilingDialog from './ui/CaseRequestFilingDialog'; +import { getEntrypointButton } from './getEntrypointButton'; +import findSectionHeading from '../../wiki/util/findSectionHeading'; +import unwrapWidget from '../../util/unwrapWidget'; +import last from '../../util/last'; +import getSectionElements from '../../wiki/util/getSectionElements'; +import equalTitle from '../../util/equalTitle'; +import normalizeTitle from '../../wiki/util/normalizeTitle'; +import classMix from '../../util/classMix'; + +declare global { + interface Window { + CCICaseRequestFiler?: CCICaseRequestFiler; + ccrfEntrypoint?: any; + } +} + +/** + * + */ +export default class CCICaseRequestFiler extends DeputyModule { + + static readonly dependencies = [ + 'oojs-ui-core', + 'oojs-ui-widgets', + 'oojs-ui-windows', + 'oojs-ui.styles.icons-movement', + 'oojs-ui.styles.icons-interactions', + 'mediawiki.jqueryMsg', + 'mediawiki.util', + 'mediawiki.api', + 'mediawiki.Title', + 'mediawiki.widgets', + 'mediawiki.widgets.UserInputWidget', + 'mediawiki.special.changeslist', + 'mediawiki.interface.helpers.styles', + 'mediawiki.pager.styles', + 'moment' + ]; + + readonly static = CCICaseRequestFiler; + + dialog: any; + + /** + * @inheritDoc + */ + getName(): string { + return 'ccrf'; + } + + /** + * @inheritDoc + */ + getModuleKey(): string { + return 'cci'; + } + + /** + * Perform actions that run *before* IA starts (prior to execution). This involves + * adding in necessary UI elements that serve as an entry point to IA. + */ + async preInit(): Promise { + mw.hook( 'ccrf.preload' ).fire(); + if ( !await super.preInit( deputyCcrfEnglish ) ) { + return false; + } + + if ( this.wikiConfig.cci.rootPage.get() == null ) { + // Root page is invalid. Don't run. + return false; + } + + await DeputyLanguage.load( 'shared', deputySharedEnglish ); + mw.util.addCSS( iaStyles ); + + mw.hook( 'ccrf.postload' ).fire(); + + if ( !window.ccrfEntrypoint ) { + // No entrypoint buttons yet. + const entrypointButton = await getEntrypointButton(); + const placeholder = document.querySelector( + '.mw-body-content .mw-parser-output .ccrf-placeholder' + ); + if ( placeholder ) { + placeholder.replaceChildren( unwrapWidget( entrypointButton ) ); + } else { + this.getWikiConfig().then( ( config ) => { + if ( !equalTitle( config.cci.rootPage.get(), normalizeTitle() ) ) { + // Not the right page. + return; + } + + const requestsHeader = findSectionHeading( config.cci.requestsHeader.get() ); + + last( getSectionElements( requestsHeader ) ).insertAdjacentElement( + 'afterend', unwrapWidget( entrypointButton ) + ); + } ); + } + } + + return true; + } + + /** + * + */ + async openWorkflowDialog(): Promise { + window.ccrfEntrypoint.setDisabled( true ); + return mw.loader.using( CCICaseRequestFiler.dependencies, async () => { + if ( !this.dialog ) { + // The following classes are used here: + // * deputy + // * cci-case-request-filer + this.dialog = CaseRequestFilingDialog( { + classes: classMix( + // Attach "deputy" class if Deputy. + this.deputy ? 'deputy' : null, + 'cci-case-request-filer' + ).split( ' ' ) + } ); + this.windowManager.addWindows( [ this.dialog ] ); + } + await this.windowManager.openWindow( this.dialog ).opened; + } ); + } + +} diff --git a/src/modules/ccrf/CCICaseRequestFilerLoader.ts b/src/modules/ccrf/CCICaseRequestFilerLoader.ts new file mode 100644 index 000000000..7baab9cf9 --- /dev/null +++ b/src/modules/ccrf/CCICaseRequestFilerLoader.ts @@ -0,0 +1,81 @@ +import getPageExists from '../../wiki/util/getPageExists'; +import WikiConfigurationLocations from '../../config/WikiConfigurationLocations'; +import getPageContent from '../../wiki/util/getPageContent'; +import { getEntrypointButton } from './getEntrypointButton'; +import findSectionHeading from '../../wiki/util/findSectionHeading'; +import DeputyLanguage from '../../DeputyLanguage'; +import deputySharedEnglish from '../../../i18n/shared/en.json'; +import deputyCcrfLoaderEnglish from '../../../i18n/ccrf-loader/en.json'; +import unwrapWidget from '../../util/unwrapWidget'; +import last from '../../util/last'; +import getSectionElements from '../../wiki/util/getSectionElements'; +import equalTitle from '../../util/equalTitle'; +import normalizeTitle from '../../wiki/util/normalizeTitle'; +import applyOverrides from '../../util/applyOverrides'; +import warn from '../../util/warn'; + +/** + * This function loads in the standalone version of the CCI Case Request Filer. + * + * The loader is meant to be lightweight to allow little to no delays in loading. + * When ready for use, the core module will be loaded with `appendEntrypointButtons`. + */ +( async () => { + await DeputyLanguage.load( 'shared', deputySharedEnglish ); + await DeputyLanguage.load( 'ccrf-loader', deputyCcrfLoaderEnglish ); + + mw.hook( 'ccrf.loader.preload' ).fire(); + + const entrypointButton = await getEntrypointButton(); + const placeholder = document.querySelector( + '.mw-body-content .mw-parser-output .ccrf-placeholder' + ); + if ( placeholder ) { + placeholder.replaceChildren( unwrapWidget( entrypointButton ) ); + } else { + const configLocations = await getPageExists( WikiConfigurationLocations ); + if ( configLocations.length > 0 ) { + const configContent = await getPageContent( configLocations[ 0 ] ); + let config; + try { + config = JSON.parse( configContent ); + } catch ( e ) { + return mw.notify( mw.msg( 'deputy.loadError.wikiConfig' ) ); + } + // #if _DEV + if ( window.deputyWikiConfigOverride ) { + warn( + 'Configuration overrides found for Deputy. This may be bad!' + ); + applyOverrides( + config, + window.deputyWikiConfigOverride, + ( key, oldVal, newVal ) => { + warn( `${key}: ${ + JSON.stringify( oldVal ) + } → ${ + JSON.stringify( newVal ) + }` ); + } + ); + } + // #endif + if ( + config && + config.cci && + config.cci.rootPage && + config.cci.requestsHeader && + config.cci.requestsTemplate && + equalTitle( config.cci.rootPage, normalizeTitle() ) + ) { + const requestsHeader = findSectionHeading( config.cci.requestsHeader ); + + last( getSectionElements( requestsHeader ) ).insertAdjacentElement( + 'afterend', unwrapWidget( entrypointButton ) + ); + } + } + } + + mw.hook( 'ccrf.loader.postload' ).fire(); +} )(); diff --git a/src/modules/ccrf/CCICaseRequestFilerStandalone.ts b/src/modules/ccrf/CCICaseRequestFilerStandalone.ts new file mode 100644 index 000000000..b27cc8a95 --- /dev/null +++ b/src/modules/ccrf/CCICaseRequestFilerStandalone.ts @@ -0,0 +1,20 @@ +import CCICaseRequestFiler from './CCICaseRequestFiler'; +import Recents from '../../wiki/Recents'; + +/** + * This function handles CCRF loading when Deputy isn't present. When Deputy is not + * present, the following must be done on our own: + * (1) Instantiate an OOUI WindowManager + * (2) Load language strings + * + * `preInit` handles all of those. This function simply calls it on run. + * + * @param window + */ +( async ( window: Window & { CCICaseRequestFiler?: CCICaseRequestFiler } ) => { + + Recents.save(); + window.CCICaseRequestFiler = new CCICaseRequestFiler(); + await window.CCICaseRequestFiler.preInit(); + +} )( window ); diff --git a/src/modules/ccrf/css/cci-case-request-filer.css b/src/modules/ccrf/css/cci-case-request-filer.css new file mode 100644 index 000000000..3624b5c54 --- /dev/null +++ b/src/modules/ccrf/css/cci-case-request-filer.css @@ -0,0 +1,129 @@ +.cci-case-request-filer .oo-ui-window-frame { + width: 700px !important; +} + +.cci-case-request-filer .wizard-background { + position: absolute; + + bottom: 0; + right: 0; + width: 50vmin; + height: 50vmin; + + opacity: 0.1; + + background: + url('https://upload.wikimedia.org/wikipedia/commons/1/14/Deputy_logo_%28on_white%29.svg') + no-repeat 5vmin 5vmin; + background-size: 50vmin; +} + +.cci-case-request-filer h1:first-child { + padding-top: 0; +} + +.cci-case-request-filer .page-navigation { + margin-top: 1.2em; + display: flex; + justify-content: space-between; +} + +.cci-case-request-filer .page-navigation--previous { + margin-right: auto; +} + +.cci-case-request-filer .page-navigation--next { + margin-left: auto; +} + +.cci-case-request-filer .oo-ui-pageLayout { + opacity: 0; + transition: opacity 0.1s ease-in-out; +} + +.cci-case-request-filer .oo-ui-pageLayout.crfd-page--active { + opacity: 1; +} + +.cci-case-request-filer .oo-ui-pageLayout > * { + transform: translateY( 1vmin ); + transition: transform 0.1s ease-in-out; +} + +.cci-case-request-filer .oo-ui-pageLayout.crfd-page--active > * { + transform: translateY( 0 ); +} + +.cci-case-request-filer .ccrf-background-check { + margin: 0.4em 0; +} + +.cci-case-request-filer .ccrf-background-check--header { + margin-bottom: 0.4em; + padding: 0.5em; + background-color: rgba(198, 213, 255, 0.1); + + position: relative; +} + +.cci-case-request-filer .ccrf-background-check--header > div:first-child > span:last-child { + margin-left: 0.4em; +} + +.cci-case-request-filer .ccrf-background-check--header > div:first-child .oo-ui-iconWidget { + margin-right: 0.4em; +} + +.cci-case-request-filer .ccrf-background-check--header--toggle { + position: absolute; + top: 0; + right: 0; +} + +.cci-case-request-filer .ccrf-background-check--header--toggle.oo-ui-toggleWidget-on .oo-ui-buttonElement-button { + background-color: initial !important; +} + +.cci-case-request-filer .ccrf-background-check--header + div { + transition: max-height 0.2s ease-in-out; + overflow: hidden; +} + +.ccrf-deleted-page--links::before { + content: attr(data-before); +} + +.ccrf-deleted-page--links::after { + content: attr(data-after); +} + +.ccrf-deleted-page--links > *:not(:last-child)::after { + content: attr(data-separator) +} + +.ccrf-deleted-page--links > *:not(:last-child)::after { + content: attr(data-separator) +} + +.ccrf-deleted-page--details > *:not(:last-child)::after { + content: attr(data-separator) +} + +.ccrf-deleted-revision--interval { + padding-bottom: 4px; + margin-top: 12px; + border-bottom: 1px solid #888; + + color: #888; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.ccrf-deleted-page, .ccrf-deleted-revision { + padding: 0.4em; +} + +.ccrf-deleted-revision--likely { + background-color: rgba(255, 0, 0, 0.08); +} diff --git a/src/modules/ccrf/getEntrypointButton.ts b/src/modules/ccrf/getEntrypointButton.ts new file mode 100644 index 000000000..1cca2fad9 --- /dev/null +++ b/src/modules/ccrf/getEntrypointButton.ts @@ -0,0 +1,60 @@ +import log from '../../util/log'; +import dynamicModuleLoad from '../dynamicModuleLoad'; +// #if _DEV +import dynamicDevModuleLoad from '../dynamicDevModuleLoad'; +// #endif _DEV + +/** + * Gets the OOUI ButtonWidget which starts the filer's main workflow dialog. Can + * be used within a loader or standalone context. + */ +export async function getEntrypointButton(): Promise { + return new Promise( ( res ) => mw.loader.using( [ + 'oojs-ui-core', + 'oojs-ui-windows', + 'oojs-ui.styles.icons-content', + 'mediawiki.util', + 'mediawiki.api' + ], () => { + const appendEntrypointButton = new OO.ui.ButtonWidget( { + icon: 'articleAdd', + classes: [ 'deputy-ccrf-entrypoint' ], + label: mw.msg( 'deputy.ccrf.start' ), + flags: [ 'progressive' ] + } ); + + appendEntrypointButton.on( 'click', () => { + if ( window.CCICaseRequestFiler ) { + window.CCICaseRequestFiler.openWorkflowDialog(); + + } else { + // #if _DEV + dynamicDevModuleLoad( 'ccrf' ) + .then( () => { + mw.hook( 'ccrf.postload' ).add( () => { + window.CCICaseRequestFiler.openWorkflowDialog(); + } ); + } ) + .catch( ( e ) => { + log( e ); + OO.ui.alert( mw.msg( 'deputy.module.loadFailure', e.message ) ); + } ); + // #else + dynamicModuleLoad( 'CCICaseRequestFiler' ) + .then( () => { + mw.hook( 'ccrf.postload' ).add( () => { + window.CCICaseRequestFiler.openWorkflowDialog(); + } ); + } ) + .catch( ( e ) => { + log( e ); + OO.ui.alert( mw.msg( 'deputy.module.loadFailure', e.message ) ); + } ); + // #endif _DEV + } + } ); + + window.ccrfEntrypoint = appendEntrypointButton; + res( appendEntrypointButton ); + } ) ); +} diff --git a/src/modules/ccrf/ui/CRFDNavigation.tsx b/src/modules/ccrf/ui/CRFDNavigation.tsx new file mode 100644 index 000000000..c67a0eeb5 --- /dev/null +++ b/src/modules/ccrf/ui/CRFDNavigation.tsx @@ -0,0 +1,106 @@ +import CaseRequestFilingDialog from './CaseRequestFilingDialog'; +import unwrapWidget from '../../../util/unwrapWidget'; +import { h } from 'tsx-dom'; +import { PromiseOrNot } from '../../../types'; + +interface CRFDNavigationOptions { + dialog: ReturnType; + page: { name: string }; + previousButton?: JSX.Element; + nextButton?: JSX.Element; + preNext?: () => PromiseOrNot; + prePrevious?: () => PromiseOrNot; +} + +/** + * + * @param root0 + * @param root0.dialog + * @param root0.prePrevious + * @return An OOUI ButtonWidget + */ +export function getCRFDNavigationPreviousButton( { dialog, prePrevious }: CRFDNavigationOptions ): + any { + const previousPageButton = new OO.ui.ButtonWidget( { + invisibleLabel: true, + label: mw.msg( 'deputy.ccrf.previous' ), + icon: 'previous', + classes: [ 'page-navigation--previous' ] + } ); + previousPageButton.on( 'click', async () => { + if ( prePrevious ) { + previousPageButton.setDisabled( true ); + const willNavigate = await prePrevious(); + previousPageButton.setDisabled( false ); + if ( willNavigate ) { + dialog.previousPage(); + } + } else { + dialog.previousPage(); + } + } ); + + return previousPageButton; +} + +/** + * + * @param root0 + * @param root0.dialog + * @param root0.preNext + * @return An OOUI ButtonWidget + */ +export function getCRFDNavigationNextButton( { dialog, preNext }: CRFDNavigationOptions ): + any { + const nextPageButton = new OO.ui.ButtonWidget( { + invisibleLabel: true, + label: mw.msg( 'deputy.ccrf.next' ), + icon: 'next', + flags: [ 'primary', 'progressive' ], + classes: [ 'page-navigation--next' ] + } ); + nextPageButton.on( 'click', async () => { + if ( preNext ) { + nextPageButton.setDisabled( true ); + const willNavigate = await preNext(); + nextPageButton.setDisabled( false ); + if ( willNavigate ) { + dialog.nextPage(); + } + } else { + dialog.nextPage(); + } + } ); + + return nextPageButton; +} + +// noinspection JSCommentMatchesSignature +/** + * Renders the navigation bar (previous/next buttons) for dialog pages. + * + * @param options + * @param options.dialog The case request filing dialog + * @param options.page The page to render the navigation bar for + * @param options.page.name The name of the page (should be constant) + * @param options.previousButton + * @param options.nextButton + * @param options.preNext A function to call before navigating to the next page. + * If this returns `false`, the navigation is cancelled. Ignored if `nextButton` is + * provided. + * @return A JSX Element + */ +export default function ( + options : CRFDNavigationOptions +) { + const { dialog, page, previousButton, nextButton } = options; + const isFirst = dialog.isFirstPage( page.name ); + const isLast = dialog.isLastPage( page.name ); + + return ; +} diff --git a/src/modules/ccrf/ui/CaseRequestFilingDialog.tsx b/src/modules/ccrf/ui/CaseRequestFilingDialog.tsx new file mode 100644 index 000000000..c9db51b71 --- /dev/null +++ b/src/modules/ccrf/ui/CaseRequestFilingDialog.tsx @@ -0,0 +1,221 @@ +import '../../../types'; +import { blockExit, unblockExit } from '../../../util/blockExit'; +import CRFDIntroPageLayout from './pages/CRFDIntroPageLayout'; +import CRFDUserSelectPageLayout from './pages/CRFDUserSelectPageLayout'; +import sleep from '../../../util/sleep'; +import { BackgroundChecks } from '../BackgroundChecks'; +import CRFDUserCheckPageLayout from './pages/CRFDUserCheckPageLayout'; + +let InternalCaseRequestFilingDialog: any; + +interface CaseRequestFilingDialogData { + /** + * Extra classes for this dialog. + */ + classes?: string[]; +} + +/** + * Initializes the process element. + */ +function initCaseRequestFilingDialog() { + InternalCaseRequestFilingDialog = class CaseRequestFilingDialog extends OO.ui.ProcessDialog { + + // For dialogs. Remove if not a dialog. + static static = { + ...OO.ui.ProcessDialog.static, + name: 'caseRequestFilingDialog', + title: mw.msg( 'deputy.ccrf.caseRequestFilingDialog.title' ), + actions: [ + { + action: 'close', + label: mw.msg( 'deputy.close' ), + flags: 'safe' + } + ] + }; + + data: any; + $body: JQuery; + + introPage: ReturnType; + userSelectPage: ReturnType; + userCheckPage: ReturnType; + layout: OO.ui.BookletLayout; + + /** + * @param config + */ + constructor( config: CaseRequestFilingDialogData ) { + super( config ); + } + + /** + * @return The body height of this dialog. + */ + getBodyHeight(): number { + return 500; + } + + /** + * Initializes the dialog. + */ + initialize() { + super.initialize(); + + this.layout = new OO.ui.BookletLayout( {} ); + + this.layout.addPages( [ + ( this.introPage = + CRFDIntroPageLayout( { dialog: this } ) ), + ( this.userSelectPage = + CRFDUserSelectPageLayout( { dialog: this } ) ), + ( this.userCheckPage = + CRFDUserCheckPageLayout( { dialog: this } ) ) + ] ); + + // Set first page as active + this.setPage( this.introPage.name, true ); + + this.$body.append( this.layout.$element ); + + return this; + } + + /** + * @return which checks are enabled on this wiki. + */ + getEnabledChecks(): BackgroundChecks { + const ccrfConfig = window.CCICaseRequestFiler.wikiConfig.ccrf; + return { + page: ccrfConfig.performPageChecks.get() && + ccrfConfig.pageDeletionFilters.get() != null, + revision: ccrfConfig.performRevisionChecks.get() && + ccrfConfig.revisionDeletionFilters.get() != null, + warnings: ccrfConfig.performWarningChecks.get() && + ccrfConfig.warningFilters.get() != null + }; + } + + /** + * Check if a page (default: current page) is the first page of this booklet. + * + * @param pageName + * @return A boolean indicating whether the page is the first page. + */ + isFirstPage( pageName: string = this.layout.getCurrentPageName() ) { + return ( Object.keys( this.layout.pages ) + .indexOf( pageName ) ) === 0; + } + + /** + * Check if a page (default: current page) is the last page of this booklet. + * + * @param pageName + * @return A boolean indicating whether the page is the last page. + */ + isLastPage( pageName: string = this.layout.getCurrentPageName() ) { + return ( Object.keys( this.layout.pages ) + .indexOf( pageName ) ) === Object.keys( this.layout.pages ).length - 1; + } + + /** + * Navigate to the previous page. + */ + previousPage() { + this.setPage( + Object.keys( this.layout.pages )[ + Object.keys( this.layout.pages ) + .indexOf( this.layout.getCurrentPageName() ) - 1 + ] + ); + } + + /** + * Navigate to the next page. + */ + nextPage() { + this.setPage( + Object.keys( this.layout.pages )[ + Object.keys( this.layout.pages ) + .indexOf( this.layout.getCurrentPageName() ) + 1 + ] + ); + } + + /** + * + * @param name + * @param skipAnimation + */ + setPage( name: string, skipAnimation = false ) { + if ( skipAnimation ) { + this.layout.getCurrentPage()?.$element?.removeClass( 'crfd-page--active' ); + this.layout.setPage( name ); + this.layout.getCurrentPage().$element.addClass( 'crfd-page--active' ); + } else { + this.layout.getCurrentPage()?.$element?.removeClass( 'crfd-page--active' ); + sleep( 100 ).then( () => { + this.layout.setPage( name ); + this.layout.getCurrentPage().$element.addClass( 'crfd-page--active' ); + } ); + } + } + + /** + * @param data + * @return An OOUI Process + */ + getSetupProcess( data: any ): any { + const process = super.getSetupProcess.call( this, data ); + + process.next( () => { + blockExit( 'ccrf-new' ); + } ); + + return process; + } + + /** + * @param action + * @return An OOUI Process + */ + getActionProcess( action: string ): any { + const process = super.getActionProcess.call( this, action ); + + process.next( function () { + unblockExit( 'ccrf-new' ); + this.close( { action: action } ); + }, this ); + + process.next( function () { + window.ccrfEntrypoint?.setDisabled( false ); + } ); + + return process; + } + + /** + * @param data + * @return An OOUI Process + */ + getTeardownProcess( data: any ): any { + /** @member any */ + return super.getTeardownProcess.call( this, data ); + } + + }; +} + +/** + * Creates a new CaseRequestFilingDialog. + * + * @param config + * @return A CaseRequestFilingDialog object + */ +export default function ( config: CaseRequestFilingDialogData = {} ) { + if ( !InternalCaseRequestFilingDialog ) { + initCaseRequestFilingDialog(); + } + return new InternalCaseRequestFilingDialog( config ); +} diff --git a/src/modules/ccrf/ui/checks/BackgroundCheck.tsx b/src/modules/ccrf/ui/checks/BackgroundCheck.tsx new file mode 100644 index 000000000..a62a1202a --- /dev/null +++ b/src/modules/ccrf/ui/checks/BackgroundCheck.tsx @@ -0,0 +1,140 @@ +import { DeputyDispatchTask } from '../../../../api/DispatchAsync'; +import unwrapWidget from '../../../../util/unwrapWidget'; +import { h } from 'tsx-dom'; +import { BackgroundChecks } from '../../BackgroundChecks'; +import swapElements from '../../../../util/swapElements'; +import { PromiseOrNot } from '../../../../types'; + +/** + * + */ +export default abstract class BackgroundCheck { + + progressBarWidget: OO.ui.ProgressBarWidget; + element: JSX.Element; + headerElement: JSX.Element; + mainElement: JSX.Element; + + /** + * + * @param checkName + * @param task + */ + protected constructor( + readonly checkName: keyof BackgroundChecks, + readonly task: DeputyDispatchTask + ) { + // Trigger render so that UI elements are available when events fire. + this.render(); + + this.task.addEventListener( 'progress', ( event: CustomEvent ) => { + this.progressBarWidget?.setProgress( event.detail * 100 ); + } ); + this.task.addEventListener( 'finished', () => { + this.progressBarWidget?.setProgress( 1 ); + this.task.waitUntilDone().then( async ( v ) => { + const message = this.getResultMessage( v ); + this.headerElement = + swapElements( + this.headerElement, + this.renderHeader( message.icon, message.message ) + ); + this.mainElement = + swapElements( this.mainElement, await this.renderCheckResults( v ) ); + } ); + } ); + } + + /** + * @param key The message key to get + * @param {...any} params + * @return a message for this specific check + */ + msg( key: string, ...params: any[] ): string { + return mw.msg( `deputy.ccrf.check.${this.checkName}.${key}`, ...params ); + } + + /** + * Renders the check's results. + * + * @param data + */ + abstract renderCheckResults( data: T ): PromiseOrNot; + + /** + * Gets the result message for the check. This displays as the "heading" of the check. + * + * @param data + */ + abstract getResultMessage( data: T ): { icon: string, message: string }; + + /** + * Renders the header + * + * @param icon + * @param message + * @return A JSX Element + */ + renderHeader( icon: string, message: string ): JSX.Element { + const header =
; + const toggleButton = new OO.ui.ToggleButtonWidget( { + classes: [ 'ccrf-background-check--header--toggle' ], + label: mw.msg( 'deputy.ccrf.check.toggle' ), + invisibleLabel: true, + icon: 'expand', + framed: false + } ); + toggleButton.on( 'change', ( state: boolean ) => { + if ( state ) { + this.mainElement.style.maxHeight = '0'; + } else { + // Transitioning to expanded state. + // Unset first to get actual height. + this.mainElement.style.maxHeight = 'unset'; + this.mainElement.style.maxHeight = this.mainElement.clientHeight + 'px'; + } + toggleButton.setIcon( state ? 'collapse' : 'expand' ); + header.classList.toggle( 'ccrf-background-check--header--collapsed', !state ); + } ); + + header.appendChild( +
+ { unwrapWidget( new OO.ui.IconWidget( { icon } ) ) } + {message} +
+ ); + header.appendChild( +
+ { unwrapWidget( toggleButton )} +
+ ); + + return header; + } + + /** + * Renders the progress bar. + * + * @return A JSX Element + */ + renderProgressBar(): JSX.Element { + return unwrapWidget( + this.progressBarWidget ?? + ( this.progressBarWidget = new OO.ui.ProgressBarWidget( { + progress: this.task.progress ?? 0 + } ) ) + ); + } + + /** + * @return The rendered page layout + */ + render() { + return this.element ?? ( this.element =
+ { this.headerElement = + this.renderHeader( 'ellipsis', this.msg( 'load' ) ) } + { this.mainElement = this.renderProgressBar() } +
); + } + +} diff --git a/src/modules/ccrf/ui/checks/DeletedPageCheck.tsx b/src/modules/ccrf/ui/checks/DeletedPageCheck.tsx new file mode 100644 index 000000000..634cd5b75 --- /dev/null +++ b/src/modules/ccrf/ui/checks/DeletedPageCheck.tsx @@ -0,0 +1,182 @@ +import BackgroundCheck from './BackgroundCheck'; +import { DispatchUserDeletedPagesResponse } from '../../../../api/types/DispatchTypes'; +import type { DeletedPage, PageDeletionInfo } from '../../../../api/models/DeletedPage'; +import { ChangesListDate } from '../../../../ui/shared/ChangesList'; +import unwrapJQ from '../../../../util/unwrapJQ'; +import msgEval from '../../../../wiki/util/msgEval'; +import nsId from '../../../../wiki/util/nsId'; +import { h } from 'tsx-dom'; +import { DeputyDispatchTask } from '../../../../api/DispatchAsync'; +import { USER_LOCALE } from '../../../../wiki/Locale'; + +/** + * Renders the header for a deleted page entry + * + * + * @param page + * @param page.page + * @return A JSX Element + */ +function DeletedPageHeader( { page }: {page: DeletedPage} ): JSX.Element { + const pageTitle = new mw.Title( page.title, page.ns ); + return
+ +
+ { + mw.msg( 'deputy.ccrf.page.history' ) + } + { + mw.msg( 'deputy.revision.bytes', `${page.length}` ) + }{ + unwrapJQ( + , + mw.message( + 'deputy.ccrf.page.details.created', + + ).parseDom() + ) + }
+
; +} + +/** + * + * @param root0 + * @param root0.page + * @return A JSX Element + */ +function DeletedPageReason( + { page }: { page: DeletedPage & { deleted: PageDeletionInfo } } +): JSX.Element { + const time = new Date( page.deleted.timestamp ); + const now = window.moment( time ); + + const formattedTime = time.toLocaleTimeString( USER_LOCALE, { + hourCycle: 'h24', + timeStyle: mw.user.options.get( 'date' ) === 'ISO 8601' ? 'long' : 'short' + } ); + const formattedDate = now.locale( USER_LOCALE ).format( { + dmy: 'D MMMM YYYY', + mdy: 'MMMM D, Y', + ymd: 'YYYY MMMM D', + 'ISO 8601': 'YYYY:MM:DD[T]HH:mm:SS' + }[ mw.user.options.get( 'date' ) as string ] ); + + const comma = mw.msg( 'comma-separator' ); + + const logPage = new mw.Title( 'Special:Redirect/logid/' + page.deleted.logid ) + .getPrefixedText(); + const userPage = new mw.Title( page.deleted.user, nsId( 'user' ) ) + .getPrefixedText(); + + return
    + {unwrapJQ( +
  • , + page.deleted.userhidden ? + mw.message( 'deputy.ccrf.page.deleted.userhidden', + logPage, + `${formattedTime}${comma}${formattedDate}`, + msgEval( page.deleted.comment ).parseDom() + ).parseDom() : + mw.message( 'deputy.ccrf.page.deleted', + userPage, + page.deleted.user, + logPage, + `${formattedTime}${comma}${formattedDate}`, + msgEval( page.deleted.comment ).parseDom() + ).parseDom() + )} +
; +} + +/** + * Renders a deleted page entry. + * + * @param root0 + * @param root0.page + * @return A JSX Element + */ +function DeletedPagePanel( + { page }: { page: DeletedPage & { deleted: PageDeletionInfo } } +): JSX.Element { + return
+ + +
; +} + +/** + * + */ +export default class DeletedPageCheck + extends BackgroundCheck { + + /** @inheritDoc */ + constructor( + readonly task: DeputyDispatchTask + ) { + super( 'page', task ); + } + + /** + * @param data + * @return Pages that match this specific filter + */ + getMatchingPages( data: DeletedPage[] ): DeletedPage[] { + const filter = window.CCICaseRequestFiler.wikiConfig.ccrf.pageDeletionFilters.get(); + const isMatching = typeof filter === 'string' ? + ( comment: string ) => comment.includes( filter ) : + ( Array.isArray( filter ) ? + ( comment: string ) => filter.some( ( f ) => comment.includes( f ) ) : + ( comment: string ) => new RegExp( filter.source, filter.flags ).test( comment ) + ); + + return data.filter( ( page ) => + typeof page.deleted === 'object' && + page.deleted.comment && + isMatching( page.deleted.comment ) ); + } + + /** + * @inheritDoc + */ + getResultMessage( data: { pages: DeletedPage[] } ): { icon: string; message: string } { + const pages = this.getMatchingPages( data.pages ); + return { + icon: pages.length > 0 ? 'check' : 'close', + message: this.msg( + pages.length > 0 ? 'match' : 'clear', `${pages.length}` + ) + }; + } + + /** + * @inheritDoc + */ + renderCheckResults( data: { pages: DeletedPage[] } ): JSX.Element { + const pageElements = []; + + for ( const page of this.getMatchingPages( data.pages ) ) { + pageElements.push( + + ); + // Ignore all deletions which don't match our conditions. + } + if ( pageElements.length === 0 ) { + return
{this.msg( 'none' )}
; + } + return
{pageElements}
; + } + +} diff --git a/src/modules/ccrf/ui/checks/DeletedRevisionCheck.tsx b/src/modules/ccrf/ui/checks/DeletedRevisionCheck.tsx new file mode 100644 index 000000000..3e995a870 --- /dev/null +++ b/src/modules/ccrf/ui/checks/DeletedRevisionCheck.tsx @@ -0,0 +1,221 @@ +import BackgroundCheck from './BackgroundCheck'; +import { DispatchUserDeletedRevisionsResponse } from '../../../../api/types/DispatchTypes'; +import { + DeletedRevision, + RevisionDeletionInfo, + TextDeletedRevision +} from '../../../../api/models/DeletedRevision'; +import { ChangesListRow } from '../../../../ui/shared/ChangesList'; +import { DeputyDispatchTask } from '../../../../api/DispatchAsync'; +import { h } from 'tsx-dom'; +import MwApi from '../../../../MwApi'; +import { USER_LOCALE } from '../../../../wiki/Locale'; +import nsId from '../../../../wiki/util/nsId'; +import unwrapJQ from '../../../../util/unwrapJQ'; +import msgEval from '../../../../wiki/util/msgEval'; +import classMix from '../../../../util/classMix'; + +/** + * Renders the header for a deleted page entry + * + * + * @param root0 + * @param root0.revision + * @return A JSX Element + */ +function DeletedRevisionHeader( { revision }: {revision: DeletedRevision} ): JSX.Element { + return
+ +
; +} + +/** + * + * @param root0 + * @param root0.revision + * @return A JSX Element + */ +function DeletedRevisionReason( + { revision }: { revision: DeletedRevision & { deleted: RevisionDeletionInfo } } +): JSX.Element { + const time = new Date( revision.deleted.timestamp ); + const now = window.moment( time ); + + const formattedTime = time.toLocaleTimeString( USER_LOCALE, { + hourCycle: 'h24', + timeStyle: mw.user.options.get( 'date' ) === 'ISO 8601' ? 'long' : 'short' + } ); + const formattedDate = now.locale( USER_LOCALE ).format( { + dmy: 'D MMMM YYYY', + mdy: 'MMMM D, Y', + ymd: 'YYYY MMMM D', + 'ISO 8601': 'YYYY:MM:DD[T]HH:mm:SS' + }[ mw.user.options.get( 'date' ) as string ] ); + + const comma = mw.msg( 'comma-separator' ); + + const logPage = new mw.Title( 'Special:Redirect/logid/' + revision.deleted.logid ) + .getPrefixedText(); + const userPage = new mw.Title( revision.deleted.user, nsId( 'user' ) ) + .getPrefixedText(); + + return
    + {unwrapJQ( +
  • , + revision.deleted.userhidden ? + mw.message( 'deputy.ccrf.page.deleted.userhidden', + logPage, + `${formattedTime}${comma}${formattedDate}`, + msgEval( revision.deleted.comment ).parseDom() + ).parseDom() : + mw.message( 'deputy.ccrf.page.deleted', + userPage, + revision.deleted.user, + logPage, + `${formattedTime}${comma}${formattedDate}`, + msgEval( revision.deleted.comment ).parseDom() + ).parseDom() + )} + { ( revision as TextDeletedRevision ).islikelycause &&
  • + { mw.msg( 'deputy.ccrf.revision.likely' ) } +
  • } +
; +} + +/** + * Renders a deleted revision entry. + * + * @param root0 + * @param root0.revision + * @return A JSX Element + */ +function DeletedRevisionPanel( + { revision }: { revision: DeletedRevision & { deleted: RevisionDeletionInfo } } +): JSX.Element { + return
+ + +
; +} + +/** + * + */ +export default class DeletedRevisionCheck + extends BackgroundCheck { + + /** @inheritDoc */ + constructor( + readonly task: DeputyDispatchTask + ) { + super( 'revision', task ); + } + + /** + * @param data + * @return Pages that match this specific filter + */ + getMatchingRevisions( data: DeletedRevision[] ): DeletedRevision[] { + const filter = window.CCICaseRequestFiler.wikiConfig.ccrf.revisionDeletionFilters.get(); + const isMatching = typeof filter === 'string' ? + ( comment: string ) => comment.includes( filter ) : + ( Array.isArray( filter ) ? + ( comment: string ) => filter.some( ( f ) => comment.includes( f ) ) : + ( comment: string ) => new RegExp( filter.source, filter.flags ).test( comment ) + ); + + return data.filter( ( revision ) => + typeof revision.deleted === 'object' && + revision.deleted.comment && + isMatching( revision.deleted.comment ) ); + } + + /** + * @inheritDoc + */ + getResultMessage( data: { revisions: DeletedRevision[] } ): { icon: string; message: string } { + const revisions = this.getMatchingRevisions( data.revisions ); + const closeRevisions = revisions + .filter( r => ( r as TextDeletedRevision ).islikelycause === true ).length; + return { + icon: revisions.length > 0 ? 'check' : 'close', + message: this.msg( + revisions.length > 0 ? ( + closeRevisions > 0 ? 'match.close' : 'match' + ) : 'clear', + revisions.length, + closeRevisions + ) + }; + } + + /** + * @inheritDoc + */ + async renderCheckResults( data: { revisions: DeletedRevision[] } ): Promise { + const revisionElements = []; + + const matchingRevisions = this.getMatchingRevisions( data.revisions ); + + // Grab tags + const tags = Array.from( matchingRevisions.values() ) + .reduce( ( acc, cur ) => { + for ( const tag of cur.tags ) { + if ( acc.indexOf( tag ) === -1 ) { + acc.push( tag ); + } + } + return acc; + }, [ 'list-wrapper', 'comma-separator' ] ); + await MwApi.action.loadMessagesIfMissing( + tags.map( ( v ) => 'tag-' + v ), { + amenableparser: true + } + ); + + // Sort from newest to oldest (to prepare for below) + matchingRevisions.sort( + // FIXME: Wasteful; numerous ephemeral Date object creation + ( a, b ) => + new Date( b.timestamp ).getTime() - new Date( a.timestamp ).getTime() + ); + // Date headers + const intervals = [ + 1e3 * 60 * 60 * 24 * 7, // Past week + 1e3 * 60 * 60 * 24 * 30, // Past month + 1e3 * 60 * 60 * 24 * 30 * 3, // Past 3 months + 1e3 * 60 * 60 * 24 * 365 // Past year + ]; + let lastInterval = null; + + const now = Date.now(); + for ( const revision of matchingRevisions ) { + const revisionTimestamp = new Date( revision.timestamp ).getTime(); + const closestInterval = + intervals.find( ( interval ) => now - revisionTimestamp < interval ); + if ( closestInterval !== lastInterval ) { + revisionElements.push( +
+ {window.moment( revisionTimestamp ).fromNow()} +
+ ); + lastInterval = closestInterval; + } + + revisionElements.push( + + ); + // Ignore all deletions which don't match our conditions. + } + if ( revisionElements.length === 0 ) { + return
{this.msg( 'none' )}
; + } + return
{revisionElements}
; + } + +} diff --git a/src/modules/ccrf/ui/pages/CRFDIntroPageLayout.tsx b/src/modules/ccrf/ui/pages/CRFDIntroPageLayout.tsx new file mode 100644 index 000000000..6e536a41d --- /dev/null +++ b/src/modules/ccrf/ui/pages/CRFDIntroPageLayout.tsx @@ -0,0 +1,92 @@ +import { h } from 'tsx-dom'; +import '../../../../types'; +import msgEval from '../../../../wiki/util/msgEval'; +import type CaseRequestFilingDialog from '../CaseRequestFilingDialog'; +import CRFDNavigation from '../CRFDNavigation'; +import unwrapJQ from '../../../../util/unwrapJQ'; + +interface CRFDIntroPageLayoutConfig { + dialog: ReturnType; +} + +let InternalCRFDIntroPageLayout: any; + +/** + * Initializes the process element. + */ +function initCRFDIntroPageLayout() { + InternalCRFDIntroPageLayout = class CRFDIntroPageLayout extends OO.ui.PageLayout { + + // OOUI internal + name: string; + dialog: ReturnType; + + /** + * @param config + */ + constructor( config: CRFDIntroPageLayoutConfig ) { + super( 'intro', { + ...config, + expanded: true + } ); + + this.dialog = config.dialog; + + // Asynchronously render the page contents so that CRFDNavigation can access + // the dialog's true page set. + setTimeout( () => { + this.$element.append( this.render() ); + } ); + } + + /** + * @return Rendered page content + */ + render(): JSX.Element[] { + const config = window.CCICaseRequestFiler.wikiConfig; + + const nextPageButton = new OO.ui.ButtonWidget( { + invisibleLabel: true, + label: mw.msg( 'deputy.ccrf.next' ), + icon: 'next', + flags: [ 'primary', 'progressive' ] + } ); + nextPageButton.on( 'click', () => { + this.dialog.nextPage(); + } ); + + return [ +