From d8ecb968319603fe29e5c4e139074e846c9dba38 Mon Sep 17 00:00:00 2001 From: ChlodAlejandro Date: Tue, 26 Jul 2022 16:22:05 +0800 Subject: [PATCH] [failing] abstractify attribution notices in CTEPD --- package-lock.json | 1 + package.json | 1 + rollup.config.js | 41 ++++- src/DeputyLanguage.ts | 2 +- src/modules/cte/CopiedTemplateEditor.ts | 4 +- src/modules/cte/models/CTEParsoidDocument.ts | 107 +++++------ .../cte/models/WikiAttributionNotices.ts | 171 ++++++++++++++++++ .../cte/ui/CopiedTemplateEditorDialog.tsx | 2 +- .../cte/ui/pages/CopiedTemplatesEmptyPage.tsx | 2 +- .../root/DeputyContributionSurveyRevision.tsx | 5 +- .../DeputyFinishedContributionSurveyRow.tsx | 3 +- src/util/nsId.ts | 11 ++ src/util/toRedirectsObject.ts | 17 ++ src/wiki/TalkPage.ts | 5 +- 14 files changed, 300 insertions(+), 72 deletions(-) create mode 100644 src/modules/cte/models/WikiAttributionNotices.ts create mode 100644 src/util/nsId.ts create mode 100644 src/util/toRedirectsObject.ts diff --git a/package-lock.json b/package-lock.json index d39ede6d..b4e1814b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "rollup-plugin-node-license": "^0.2.1", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-string": "^3.0.0", + "rollup-pluginutils": "^2.8.2", "serve-handler": "^6.1.3", "shx": "^0.3.4", "ts-jest": "^28.0.5", diff --git a/package.json b/package.json index baf1791f..f1c0ad5b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "rollup-plugin-node-license": "^0.2.1", "rollup-plugin-sourcemaps": "^0.6.3", "rollup-plugin-string": "^3.0.0", + "rollup-pluginutils": "^2.8.2", "serve-handler": "^6.1.3", "shx": "^0.3.4", "ts-jest": "^28.0.5", diff --git a/rollup.config.js b/rollup.config.js index d0ece9af..46c49e3a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,15 +1,17 @@ import typescript from 'rollup-plugin-typescript2'; import sourcemaps from 'rollup-plugin-sourcemaps'; import { nodeResolve } from '@rollup/plugin-node-resolve'; -import { string } from 'rollup-plugin-string'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import license from 'rollup-plugin-node-license'; +import { createFilter } from 'rollup-pluginutils'; import * as fs from 'fs'; import * as path from 'path'; const production = process.env.NODE_ENV === 'production'; +// UTILS + /** * Converts standard text to block comments. * @@ -43,6 +45,35 @@ function loadBanner( ...bannerPath ) { return blockCommentIfy( fs.readFileSync( path.join( __dirname, ...bannerPath ) ) ); } +// TRANSFORMS + +/** + * Loads CSS files as strings. Facilitates CSS loading through `mw.util`. + * + * @param {Object} options Options for the plugin. + * @param {boolean} options.minify Whether to perform a simple CSS minify. + * @return The plugin + */ +function cssString( options = { minify: true } ) { + const filter = createFilter( 'src/**/*.css' ); + + return { + name: 'CSS as string', + transform( code, id ) { + if ( filter( id ) ) { + return { + code: `export default ${JSON.stringify( + options.minify ? code.replace( /[\n\t]/g, '' ) : code + )};`, + map: { mappings: '' } + }; + } + } + }; +} + +// OPTIONS + /** * Get plugins for this Rollup instance. * @@ -50,6 +81,8 @@ function loadBanner( ...bannerPath ) { */ function getPlugins() { return [ + // Appends license information + license(), // Inserts sourcemaps !production && sourcemaps(), // Makes Common.js imports possible @@ -60,10 +93,8 @@ function getPlugins() { typescript(), // Allows JSON imports (i18n files) json(), - // Allows file imports as standard strings (CSS files) - string( { include: 'src/**/*.css' } ), - // Appends license information - license() + // Transform CSS to standard JS strings + cssString() ].filter( ( v ) => !!v ); } diff --git a/src/DeputyLanguage.ts b/src/DeputyLanguage.ts index 8e073cbe..b22f1864 100644 --- a/src/DeputyLanguage.ts +++ b/src/DeputyLanguage.ts @@ -27,7 +27,7 @@ export default class DeputyLanguage { // of translations), but the user has a 'wgUserLanguage' differing from // English. mw.notify( - // No languages to fall back on. Do not translate this string. + // No languages to fall back on. Do not translate this string. 'Deputy: Could not load requested language file.', { type: 'error' } ); diff --git a/src/modules/cte/CopiedTemplateEditor.ts b/src/modules/cte/CopiedTemplateEditor.ts index 45e0a62c..95284a87 100644 --- a/src/modules/cte/CopiedTemplateEditor.ts +++ b/src/modules/cte/CopiedTemplateEditor.ts @@ -7,6 +7,7 @@ import CopiedTemplate from './models/CopiedTemplate'; import cteStyles from './css/copied-template-editor.css'; import deputyCteEnglish from '../../../i18n/cte/en.json'; import DeputyLanguage from '../../DeputyLanguage'; +import WikiAttributionNotices from './models/WikiAttributionNotices'; declare global { interface Window { @@ -168,8 +169,9 @@ export default class CopiedTemplateEditor { 'mediawiki.widgets', 'mediawiki.widgets.datetime', 'jquery.makeCollapsible' - ], () => { + ], async () => { mw.util.addCSS( cteStyles ); + await WikiAttributionNotices.init(); if ( !this.dialog ) { // The following classes are used here: diff --git a/src/modules/cte/models/CTEParsoidDocument.ts b/src/modules/cte/models/CTEParsoidDocument.ts index c9ef5fc3..15c05729 100644 --- a/src/modules/cte/models/CTEParsoidDocument.ts +++ b/src/modules/cte/models/CTEParsoidDocument.ts @@ -1,5 +1,7 @@ import ParsoidDocument from '@chlodalejandro/parsoid'; import last from '../../../util/last'; +import AttributionNotice from './AttributionNotice'; +import WikiAttributionNotices from './WikiAttributionNotices'; import CopiedTemplate from './CopiedTemplate'; /** @@ -7,13 +9,13 @@ import CopiedTemplate from './CopiedTemplate'; */ export class TemplateInsertEvent extends Event { - template: CopiedTemplate; + template: AttributionNotice; /** * @param template The template that was inserted * @param eventInitDict */ - constructor( template: CopiedTemplate, eventInitDict?: EventInit ) { + constructor( template: AttributionNotice, eventInitDict?: EventInit ) { super( 'templateInsert', eventInitDict ); this.template = template; } @@ -64,25 +66,15 @@ export default class CTEParsoidDocument extends ParsoidDocument { '
'; /** - * Aliases of the {{copied}} template. This must be in lowercase and all - * spaces must be replaced with underscores. + * A map of all Parsoid HTML elements and their attribution notices. When notices are + * detected, they are added here. ParsoidTemplateTransclusionNode is not used here + * since they are regenerated every time `findTemplate` is called. */ - static readonly copiedTemplateAliases = [ - 'copied', - 'copied_from', - 'copywithin' - ]; - - /** - * A list of {{copied}} notices in the document. - * - * @type {CopiedTemplate[]} - */ - copiedNotices: CopiedTemplate[]; + notices: Map = new Map(); /** * The original number of {{copied}} notices in the document. */ - originalNoticeCount: number; + originalCount: number = null; /** * Creates a new CTE-specific ParsoidDocument. This extends from the existing @@ -100,8 +92,8 @@ export default class CTEParsoidDocument extends ParsoidDocument { return; } - this.findCopiedNotices(); - this.originalNoticeCount = this.copiedNotices.length; + const notices = this.findCopiedNotices(); + this.originalCount = notices.length; } ); } @@ -110,52 +102,53 @@ export default class CTEParsoidDocument extends ParsoidDocument { */ reset() { super.reset(); - this.originalNoticeCount = undefined; - this.copiedNotices = undefined; + this.originalCount = undefined; + this.notices.clear(); } /** - * Finds this document's {{copied}} notices. + * Finds all content attribution notices in the talk page. This includes {{copied}}, + * {{merged to}}, {{merged from}}, etc. + * + * @return An array of AttributionNotice objects. */ - findCopiedNotices() { - if ( !this.copiedNotices ) { - this.copiedNotices = []; - } - - const newCopiedNotices: CopiedTemplate[] = []; + findNotices(): AttributionNotice[] { this.buildIndex(); + // Used instead of `this.notices.values()` to exclude nodes that are no longer on the DOM. + const notices: AttributionNotice[] = []; - for ( const templateElement of this.findTemplate( - new RegExp( - CTEParsoidDocument.copiedTemplateAliases.map( - ( v ) => `(${mw.util.escapeRegExp( v )})` - ).join( '|' ), - 'gi' - ) - ) ) { - // This is a copied template. - const existing = this.copiedNotices.find( - ( v ) => v.element === templateElement.originalElement - ); - if ( existing ) { - // Record exists, reuse that same object (prevents memory leaks). - newCopiedNotices.push( existing ); - } else { - // Not yet in the existing array, create a new object. - const notice = new CopiedTemplate( - CTEParsoidTransclusionTemplateNode.upgradeNode( templateElement, this ) - ); - newCopiedNotices.push( - notice + for ( + const node of this.findTemplate( WikiAttributionNotices.templateAliasRegExp ) + ) { + if ( !this.notices.has( node.originalElement ) ) { + // Notice not yet cached, but this is an attribution notice. + // Now to determine what type. + const type = WikiAttributionNotices.getTemplateNoticeType( + node.getTarget().href ); - notice.addEventListener( 'destroy', () => { - const i = this.copiedNotices.indexOf( notice ); - this.copiedNotices.splice( i, 1 ); - } ); + + const noticeInstance = new ( + WikiAttributionNotices.attributionNoticeClasses[ type ] + )( CTEParsoidTransclusionTemplateNode.upgradeNode( node, this ) ); + this.notices.set( node.originalElement, noticeInstance ); } + + notices.push( this.notices.get( node.originalElement ) ); } - this.copiedNotices = newCopiedNotices; + return notices; + } + + /** + * Finds this document's {{copied}} notices. + * + * @return An array of all CopiedTemplate objects found + */ + findCopiedNotices(): CopiedTemplate[] { + return this.findNotices().filter( + ( notice ) => notice instanceof + WikiAttributionNotices.attributionNoticeClasses.copied + ) as CopiedTemplate[]; } /** @@ -237,9 +230,7 @@ export default class CTEParsoidDocument extends ParsoidDocument { // Insert. element.insertAdjacentElement( position, template ); this.findCopiedNotices(); - const templateObject = this.copiedNotices.find( - ( v ) => v.element === template - ); + const templateObject = this.notices.get( template ); this.dispatchEvent( new TemplateInsertEvent( templateObject ) ); } } diff --git a/src/modules/cte/models/WikiAttributionNotices.ts b/src/modules/cte/models/WikiAttributionNotices.ts new file mode 100644 index 00000000..5f33f50a --- /dev/null +++ b/src/modules/cte/models/WikiAttributionNotices.ts @@ -0,0 +1,171 @@ +import nsId from '../../../util/nsId'; +import getObjectValues from '../../../util/getObjectValues'; +import toRedirectsObject from '../../../util/toRedirectsObject'; +import AttributionNotice from './AttributionNotice'; +import CopiedTemplate from './CopiedTemplate'; +import CopiedTemplatePage from '../ui/pages/CopiedTemplatePage'; + +/** + * An object mapping notice types to their expected on-wiki page titles. + */ +export const attributionNoticeTemplatePages = { + copied: 'Copied' + // mergedFrom: 'Merged-from', + // mergedTo: 'Merged-to', + // translatedPage: 'Translated page', + // backwardsCopy: 'Backwards copy' +}; + +/** + * Supported attribution notice types, as a type. + */ +export type SupportedAttributionNoticeType = keyof typeof attributionNoticeTemplatePages; +type AttributionTemplateAliases = Record; + +/** + * This class contains functions, utilities, and other variables that assist in connecting + * attribution notice templates on-wiki and converting them into their AttributionNotice + * counterparts. + */ +export default class WikiAttributionNotices { + + /** + * An object mapping all supported attribution notice templates to their template pages titles. + * + * TODO: l10n - Add ability to override template names/disable templates. + */ + static attributionNoticeTemplates: Record; + /** + * An object containing aliases of all supported attribution notices (as `mw.Title`s). + */ + static templateAliasCache: AttributionTemplateAliases; + /** + * An object mapping the `getPrefixedDb` values of `templateAliasCache` to a notice type. + */ + static templateAliasKeymap: Record; + /** + * A regular expression that matches the `href` (link) of a valid attribution + * notice. Includes redirects. + */ + static templateAliasRegExp: RegExp; + /** + * An object mapping notice types to their respective class. + */ + static readonly attributionNoticeClasses = { + copied: CopiedTemplate + // TODO: Implement + // mergedFrom: class Null {}, + // TODO: Implement + // mergedTo: class Null {}, + // TODO: Implement + // translatedPage: class Null {}, + // TODO: Implement + // backwardsCopy: class Null {} + }; + /** + * An object mapping notice types to their respective OOUI PageLayouts. + */ + static readonly attributionNoticeClassPages: + Record any> = { + copied: CopiedTemplatePage + // mergedFrom: null, + // mergedTo: null, + // translatedPage: null, + // backwardsCopy: null + }; + + /** + * Initializes. + */ + static async init(): Promise { + const attributionNoticeTemplates: Record = {}; + const templateAliasCache: Record = {}; + for ( const key of Object.keys( attributionNoticeTemplatePages ) ) { + attributionNoticeTemplates[ key ] = new mw.Title( key, nsId( 'template' ) ); + templateAliasCache[ key ] = [ attributionNoticeTemplates[ key ] ]; + } + this.attributionNoticeTemplates = attributionNoticeTemplates as + typeof WikiAttributionNotices.attributionNoticeTemplates; + this.templateAliasCache = templateAliasCache as + typeof WikiAttributionNotices.templateAliasCache; + + // templateAliasCache setup + + const aliasRequest = await window.deputy.wiki.get( { + action: 'query', + format: 'json', + prop: 'linkshere', + titles: getObjectValues( this.attributionNoticeTemplates ) + .map( ( v: mw.Title ) => v.getPrefixedText() ) + .join( '|' ), + lhprop: 'title', + lhnamespace: nsId( 'template' ), + lhshow: 'redirect', + lhlimit: '500' + } ); + const aliasRequestRedirects = toRedirectsObject( aliasRequest.query.redirects ); + for ( const page of aliasRequest.query.pages ) { + let cacheKey: SupportedAttributionNoticeType; + + // Find the key of this page in the list of attribution notice templates. + // Slightly expensive, but this init should only be run once anyway. + for ( const key in this.attributionNoticeTemplates ) { + const templatePage = this.attributionNoticeTemplates[ + key as SupportedAttributionNoticeType + ].getPrefixedText(); + if ( + // Page is a perfect match. + templatePage === page.title || + // If the page was moved, and the page originally listed above is a redirect. + // This checks if the resolved redirect matches the input page. + aliasRequestRedirects[ templatePage ] === page.title + ) { + cacheKey = key as SupportedAttributionNoticeType; + break; + } + } + if ( !cacheKey ) { + // Unexpected key not found. Page must have been moved or modified. + // Give up here. + continue; + } + + const links = page.linkshere.map( ( v: { title: string } ) => new mw.Title( v.title ) ); + this.templateAliasCache[ cacheKey ].push( ...links ); + } + + // templateAliasKeymap setup + this.templateAliasKeymap = {}; + for ( const noticeType in this.templateAliasCache ) { + for ( const title of this.templateAliasCache[ + noticeType as SupportedAttributionNoticeType + ] ) { + this.templateAliasKeymap[ title.getPrefixedDb() ] = noticeType as + SupportedAttributionNoticeType; + } + } + + // templateAliasRegExp setup + + const summarizedTitles = []; + for ( const titles of getObjectValues( this.templateAliasCache ) ) { + summarizedTitles.push( titles ); + } + this.templateAliasRegExp = new RegExp( + summarizedTitles.map( ( v ) => `(${mw.util.escapeRegExp( v )})` ).join( '|' ), + 'g' + ); + } + + /** + * Get the notice type of a given template from its href string, or `undefined` if it + * is not a valid notice. + * + * @param href The href of the template. + * @return A notice type string. + */ + static getTemplateNoticeType( href: string ): SupportedAttributionNoticeType { + return this.templateAliasKeymap[ href.replace( /^\.\//, '' ) ]; + } + +} diff --git a/src/modules/cte/ui/CopiedTemplateEditorDialog.tsx b/src/modules/cte/ui/CopiedTemplateEditorDialog.tsx index 0e13351f..2f8c7dd5 100644 --- a/src/modules/cte/ui/CopiedTemplateEditorDialog.tsx +++ b/src/modules/cte/ui/CopiedTemplateEditorDialog.tsx @@ -385,7 +385,7 @@ function initCopiedTemplateEditorDialog() { text: await this.parsoid.toWikitext(), // TODO: l10n summary: decorateEditSummary( `${ - this.parsoid.originalNoticeCount > 0 ? + this.parsoid.originalCount > 0 ? 'Modifying' : 'Adding' } content attribution notices` ) } ).catch( errorToOO ); diff --git a/src/modules/cte/ui/pages/CopiedTemplatesEmptyPage.tsx b/src/modules/cte/ui/pages/CopiedTemplatesEmptyPage.tsx index a59ac181..943d039f 100644 --- a/src/modules/cte/ui/pages/CopiedTemplatesEmptyPage.tsx +++ b/src/modules/cte/ui/pages/CopiedTemplatesEmptyPage.tsx @@ -69,7 +69,7 @@ function initCopiedTemplatesEmptyPage() { },

{ mw.message( - this.parsoid.originalNoticeCount > 0 ? + this.parsoid.originalCount > 0 ? 'deputy.cte.empty.removed' : 'deputy.cte.empty.none' ).text() diff --git a/src/ui/root/DeputyContributionSurveyRevision.tsx b/src/ui/root/DeputyContributionSurveyRevision.tsx index 0bb5bb1a..ba2b2f8a 100644 --- a/src/ui/root/DeputyContributionSurveyRevision.tsx +++ b/src/ui/root/DeputyContributionSurveyRevision.tsx @@ -5,6 +5,7 @@ import getRevisionDiffURL from '../../util/getRevisionDiffURL'; import unwrapWidget from '../../util/unwrapWidget'; import { DeputyMessageEvent, DeputyRevisionStatusUpdateMessage } from '../../DeputyCommunications'; import type DeputyContributionSurveyRow from './DeputyContributionSurveyRow'; +import nsId from '../../util/nsId'; /** * A specific revision for a section row. @@ -181,11 +182,11 @@ export default class DeputyContributionSurveyRevision const userPage = new mw.Title( this.revision.user, - mw.config.get( 'wgNamespaceIds' ).user + nsId( 'user' ) ); const userTalkPage = new mw.Title( this.revision.user, - mw.config.get( 'wgNamespaceIds' ).user_talk + nsId( 'user_talk' ) ); const userContribsPage = new mw.Title( 'Special:Contributions/' + this.revision.user diff --git a/src/ui/root/DeputyFinishedContributionSurveyRow.tsx b/src/ui/root/DeputyFinishedContributionSurveyRow.tsx index 8ae656f6..26b9251d 100644 --- a/src/ui/root/DeputyFinishedContributionSurveyRow.tsx +++ b/src/ui/root/DeputyFinishedContributionSurveyRow.tsx @@ -2,6 +2,7 @@ import { h } from 'tsx-dom'; import '../../types'; import ContributionSurveyRow from '../../models/ContributionSurveyRow'; import guessAuthor from '../../util/guessAuthor'; +import nsId from '../../util/nsId'; /** * Displayed when a ContributionSurveyRow has no remaining diffs. Deputy is not able @@ -64,7 +65,7 @@ export default class DeputyFinishedContributionSurveyRow { if ( this.author ) { const userPage = new mw.Title( - this.author, mw.config.get( 'wgNamespaceIds' ).user + this.author, nsId( 'user' ) ); const talkPage = userPage.getTalkPage(); const contribsPage = new mw.Title( 'Special:Contributions/' + this.author ); diff --git a/src/util/nsId.ts b/src/util/nsId.ts new file mode 100644 index 00000000..404e9c7f --- /dev/null +++ b/src/util/nsId.ts @@ -0,0 +1,11 @@ +/** + * Gets the namespace ID from a canonical (not localized) namespace name. + * + * @param namespace The namespace to get + * @return The namespace ID + */ +export default function nsId( namespace: string ): number { + return mw.config.get( 'wgNamespaceIds' )[ + namespace.toLowerCase().replace( / /g, '_' ) + ]; +} diff --git a/src/util/toRedirectsObject.ts b/src/util/toRedirectsObject.ts new file mode 100644 index 00000000..72dc616b --- /dev/null +++ b/src/util/toRedirectsObject.ts @@ -0,0 +1,17 @@ +/** + * Transforms the `redirects` object returned by MediaWiki's `query` action into an + * object instead of an array. + * + * @param redirects + */ +export default function toRedirectsObject( + redirects: { from: string, to: string }[] +): Record { + const out: Record = {}; + + for ( const redirect of redirects ) { + out[ redirect.from ] = redirect.to; + } + + return out; +} diff --git a/src/wiki/TalkPage.ts b/src/wiki/TalkPage.ts index b62dbc81..d080426e 100644 --- a/src/wiki/TalkPage.ts +++ b/src/wiki/TalkPage.ts @@ -1,5 +1,6 @@ import normalizeTitle from '../util/normalizeTitle'; import decorateEditSummary from '../util/decorateEditSummary'; +import nsId from '../util/nsId'; /** * Options for performing edits with {@link TalkPage}. @@ -52,7 +53,7 @@ export default class TalkPage { Object.assign( { // Overridable options. redirect: this.talkPage.getNamespaceId() !== - mw.config.get( 'wgNamespaceIds' ).user_talk + nsId( 'user_talk' ) }, editOptions, { // Non-overridable options action: 'edit', @@ -86,7 +87,7 @@ export default class TalkPage { Object.assign( { // Overridable options. redirect: this.talkPage.getNamespaceId() !== - mw.config.get( 'wgNamespaceIds' ).user_talk + nsId( 'user_talk' ) }, editOptions, { // Non-overridable options summary: decorateEditSummary( options.summary )