From 8719596cfd72a9c8c11895748bf2750548547f30 Mon Sep 17 00:00:00 2001 From: "d.savuljesku" Date: Mon, 13 May 2024 16:24:12 +0200 Subject: [PATCH] Inbox merging #1 --- extension.json | 1 + includes/Inbox/HTMLDiffFormatter.php | 276 +++++++++++++++++++++++++++ includes/Inbox/TreeBuilder.php | 5 +- includes/SpecialInbox.php | 59 +++++- modules/ui/Inbox/CombinedTreeNode.js | 36 ++++ modules/ui/Inbox/ComparePanel.js | 6 +- modules/ui/Inbox/TreeNode.js | 3 +- modules/ui/Inbox/TreePanel.js | 38 +++- modules/ui/Inbox/style.less | 26 +++ 9 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 includes/Inbox/HTMLDiffFormatter.php create mode 100644 modules/ui/Inbox/CombinedTreeNode.js diff --git a/extension.json b/extension.json index 38058f56..91bb7722 100644 --- a/extension.json +++ b/extension.json @@ -430,6 +430,7 @@ "ui/Inbox/ComparePanel.js", "ui/Inbox/TreePanel.js", "ui/Inbox/TreeNode.js", + "ui/Inbox/CombinedTreeNode.js", "ext.DataAccounting.inbox.compare.js" ], "styles": [ diff --git a/includes/Inbox/HTMLDiffFormatter.php b/includes/Inbox/HTMLDiffFormatter.php new file mode 100644 index 00000000..8f660835 --- /dev/null +++ b/includes/Inbox/HTMLDiffFormatter.php @@ -0,0 +1,276 @@ + 0, 'delete' => 0 ]; + /** @var array */ + protected $arrayData = []; + /** @var int */ + protected $idCounter = 0; + /** @var string */ + protected $html = ''; + + /** + * Parses diff to HTML + * + * @param \Diff $diff + * @param bool $block If true, every line will be its own block + * @return string + */ + public function format( $diff, $block = true ) { + $this->html = ''; + + $this->html = \Html::openElement( 'div', [ + 'class' => 'da-diff', + 'id' => 'da-diff' + ] ); + + $this->idCounter = 0; + + foreach ( $diff->getEdits() as $edit ) { + switch ( $edit->getType() ) { + case 'add': + if ( $block ) { + $this->blockAdd( $edit ); + } else { + $this->lineByLineAdd( $edit ); + } + break; + case 'delete': + if ( $block ) { + $this->blockDelete( $edit ); + } else { + $this->lineByLineDelete( $edit ); + } + break; + case 'change': + [ $orig, $closing ] = $this->conflateChange( $edit ); + if ( $block ) { + $this->blockChange( $orig, $closing ); + } else { + $this->lineByLineChange( $orig, $closing ); + } + break; + case 'copy': + // Copy is always rendered as block + $this->blockCopy( $edit ); + } + } + + $this->html .= \Html::closeElement( 'div' ); + return $this->html; + } + + /** + * + * @return array + */ + public function getArrayData() { + return $this->arrayData; + } + + /** + * + * @return array + */ + public function getChangeCount() { + $changeCount = [ 'add' => 0, 'delete' => 0 ]; + foreach ( $this->arrayData as $changeId => $change ) { + if ( $change[ 'type' ] === 'add' ) { + $changeCount[ 'add' ]++; + } elseif ( $change[ 'type' ] === 'delete' ) { + $changeCount[ 'delete' ]++; + } elseif ( $change[ 'type' ] === 'change' ) { + $changeCount[ 'add' ]++; + $changeCount[ 'delete' ]++; + } + } + return $changeCount; + } + + /** + * + * @param string $diff + * @param string $type + * @param bool|false $counter + * @return string + */ + protected function getDiffHTML( $diff, $type, $counter = true ) { + $attrs = [ + 'class' => "da-diff-$type", + 'data-diff' => $type + ]; + if ( $counter ) { + $attrs['data-diff-id'] = $this->idCounter; + } + + $html = \Html::openElement( 'p', $attrs ); + foreach ( explode( "\n", $diff ) as $ln ) { + if ( empty( $ln ) ) { + $html .= \Html::element( 'span', [ 'class' => 'empty-line' ], $ln ); + } else { + $html .= \Html::element( 'span', [], $ln ); + } + } + $html .= \Html::closeElement( 'p' ); + return $html; + } + + /** + * + * @param \DiffOp $edit + * @return array + */ + protected function conflateChange( \DiffOp $edit ) { + $orig = $edit->getOrig(); + $closing = $edit->getClosing(); + + if ( count( $orig ) > count( $closing ) ) { + return array_reverse( $this->mergeUneven( $closing, $orig ) ); + } elseif ( count( $closing ) > count( $orig ) ) { + return $this->mergeUneven( $orig, $closing ); + } + return [ $orig, $closing ]; + } + + /** + * + * @param array $ar1 + * @param array $ar2 + * @return array + */ + protected function mergeUneven( $ar1, $ar2 ) { + $i = 0; + $res = []; + while ( $i < count( $ar1 ) - 1 ) { + $i++; + $res[] = $ar2[$i]; + } + $res[] = implode( "\n", array_diff( $ar2, $res ) ); + return [ $ar1, $res ]; + } + + /** + * + * @param DiffOp $edit + */ + protected function blockAdd( $edit ) { + $closingAll = implode( "\n", $edit->getClosing() ); + $this->idCounter++; + $this->html .= $this->getDiffHTML( $closingAll, 'add' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'add', + 'new' => $closingAll + ]; + } + + /** + * + * @param DiffOp $edit + */ + protected function lineByLineAdd( $edit ) { + foreach ( $edit->getClosing() as $line ) { + $this->idCounter++; + $this->html .= $this->getDiffHTML( $line, 'add' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'add', + 'new' => $line + ]; + } + } + + /** + * + * @param DiffOp $edit + */ + protected function blockDelete( $edit ) { + $origAll = implode( "\n", $edit->getOrig() ); + $this->idCounter++; + $this->html .= $this->getDiffHTML( $origAll, 'delete' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'delete', + 'old' => $origAll + ]; + } + + /** + * + * @param DiffOp $edit + */ + protected function lineByLineDelete( $edit ) { + foreach ( $edit->getOrig() as $line ) { + $this->idCounter++; + $this->html .= $this->getDiffHTML( $line, 'delete' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'delete', + 'old' => $line + ]; + } + } + + /** + * + * @param array $orig + * @param array $closing + */ + protected function blockChange( $orig, $closing ) { + $origAll = implode( "\n", $orig ); + $closingAll = implode( "\n", $closing ); + $this->idCounter++; + $this->html .= \Html::openElement( 'div', [ + 'class' => 'da-diff-change', + 'data-diff' => 'change', + 'data-diff-id' => $this->idCounter + ] ); + $this->html .= $this->getDiffHTML( $origAll, 'delete', false ); + $this->html .= $this->getDiffHTML( $closingAll, 'add', false ); + $this->html .= \Html::closeElement( 'div' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'change', + 'old' => $origAll, + 'new' => $closingAll + ]; + } + + /** + * + * @param array $orig + * @param array $closing + */ + protected function lineByLineChange( $orig, $closing ) { + foreach ( $orig as $key => $line ) { + $this->idCounter++; + $this->html .= \Html::openElement( 'div', [ + 'class' => 'da-diff-change', + 'data-diff' => 'change', + 'data-diff-id' => $this->idCounter + ] ); + $this->html .= $this->getDiffHTML( $line, 'delete', false ); + $this->html .= $this->getDiffHTML( $closing[$key], 'add', false ); + $this->html .= \Html::closeElement( 'div' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'change', + 'old' => $line, + 'new' => $closing[$key] + ]; + } + } + + /** + * + * @param DiffOp $edit + */ + protected function blockCopy( $edit ) { + $text = implode( "\n", $edit->getOrig() ); + $this->idCounter++; + $this->html .= $this->getDiffHTML( $text, 'copy' ); + $this->arrayData[ $this->idCounter ] = [ + 'type' => 'copy', + 'old' => $text + ]; + } +} diff --git a/includes/Inbox/TreeBuilder.php b/includes/Inbox/TreeBuilder.php index 859639d0..1cf59f4f 100644 --- a/includes/Inbox/TreeBuilder.php +++ b/includes/Inbox/TreeBuilder.php @@ -47,7 +47,10 @@ public function buildPreImportTree( Title $remote, Title $local, Language $langu ] ); $localEntities = $this->verifyAndReduce( $localEntities ); - return $this->combine( $remoteEntities, $localEntities, $language, $user ); + $combined = $this->combine( $remoteEntities, $localEntities, $language, $user ); + $combined['remote'] = $remote; + $combined['local'] = $local; + return $combined; } /** diff --git a/includes/SpecialInbox.php b/includes/SpecialInbox.php index 743c2b02..13f98b13 100644 --- a/includes/SpecialInbox.php +++ b/includes/SpecialInbox.php @@ -2,15 +2,19 @@ namespace DataAccounting; +use DataAccounting\Inbox\HTMLDiffFormatter; use DataAccounting\Inbox\InboxImporter; use DataAccounting\Inbox\Pager; use DataAccounting\Verification\Entity\VerificationEntity; use DataAccounting\Verification\VerificationEngine; use Html; use HTMLForm; +use MediaWiki\MediaWikiServices; use MediaWiki\Revision\RevisionLookup; use Message; use SpecialPage; +use TextContent; +use Title; use TitleFactory; class SpecialInbox extends SpecialPage { @@ -105,7 +109,6 @@ private function outputCompare( VerificationEntity $draft, VerificationEntity $t $draft->getTitle(), $target->getTitle(), $this->getLanguage(), $this->getUser() ); $this->getOutput()->addHTML( $this->makeSummaryHeader( $tree, $draft, $target ) ); - $this->getOutput()->addHTML( Html::element( 'div', [ 'id' => 'da-specialinbox-compare', @@ -114,6 +117,15 @@ private function outputCompare( VerificationEntity $draft, VerificationEntity $t 'data-target' => $target->getTitle()->getArticleID(), ] ) ); + if ( $tree['change-type'] === 'both' ) { + $diff = $this->makeDiff( $tree ); + if ( $diff ) { + $this->getOutput()->addHTML( Html::rawElement( 'div', [ + 'id' => 'da-specialinbox-compare-diff', + 'data-diff' => json_encode( $diff['diffData'] ), + ], $diff['formatted'] ) ); + } + } $this->outputForm( $draft, $tree['change-type'] ); $this->getOutput()->addModules( 'ext.DataAccounting.inbox.compare' ); } @@ -288,4 +300,49 @@ private function doMergeRemote() { $this->getOutput()->redirect( $targetTitle->getFullURL() ); return true; } + + /** + * @param array $tree + * @return array|null + * @throws \MediaWiki\Diff\ComplexityException + */ + private function makeDiff( array $tree ): ?array { + $diff = $this->getDiff( $tree['local'], $tree['remote'] ); + $diffFormatter = new HTMLDiffFormatter(); + + if ( empty( $diff ) ) { + return null; + } + return [ + 'formatted' => $diffFormatter->format( $diff ), + 'diffData' => $diffFormatter->getArrayData(), + 'count' => $diffFormatter->getChangeCount() + ]; + } + + /** + * @param Title $local + * @param Title $remote + * @return \Diff + * @throws \MediaWiki\Diff\ComplexityException + */ + protected function getDiff( \Title $local, \Title $remote ) { + $localContent = $this->getPageContentText( $local ); + $remoteContent = $this->getPageContentText( $remote ); + + return new \Diff( + explode( "\n", $localContent ), + explode( "\n", $remoteContent ) + ); + } + + /** + * @param Title $title + * @return string + */ + protected function getPageContentText( Title $title ): string { + $wikipage = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title ); + $content = $wikipage->getContent(); + return ( $content instanceof TextContent ) ? $content->getText() : ''; + } } diff --git a/modules/ui/Inbox/CombinedTreeNode.js b/modules/ui/Inbox/CombinedTreeNode.js new file mode 100644 index 00000000..492b356c --- /dev/null +++ b/modules/ui/Inbox/CombinedTreeNode.js @@ -0,0 +1,36 @@ +da = window.da || {}; +da.ui = window.da.ui || {}; + +da.ui.CombinedTreeNode = function ( data ) { + da.ui.CombinedTreeNode.super.call( this, '-1', { source: 'combined', parents: { local: data.local, remote: data.remote } } ); +}; + +OO.inheritClass( da.ui.CombinedTreeNode, da.ui.TreeNode ); + +da.ui.CombinedTreeNode.prototype.makeGraphPart = function () { + this.makeRelevantNode(); + this.$element.append( this.$relevantNode ); + this.$element.append( $( '' ).addClass( 'da-compare-node-graph da-compare-node-graph-placeholder' ) ); +}; + +da.ui.CombinedTreeNode.prototype.makeRelevantNode = function () { + var parents = ''; + console.log( this.nodeData ); + if ( this.nodeData.parents.local ) { + parents += this.nodeData.parents.local; + } + if ( this.nodeData.parents.remote ) { + parents += ',' + this.nodeData.parents.remote; + } + var classes = [ 'da-compare-node-graph', 'da-compare-node-graph-' + this.getType() ]; + this.$relevantNode = $( '' ).addClass( classes.join( ' ' ) ) + .attr( 'parent', parents ); +}; + +da.ui.CombinedTreeNode.prototype.makeLabel = function () { + // NOOP +}; + +da.ui.CombinedTreeNode.prototype.getType = function () { + return 'combined'; +}; \ No newline at end of file diff --git a/modules/ui/Inbox/ComparePanel.js b/modules/ui/Inbox/ComparePanel.js index ad1147bb..bbb93495 100644 --- a/modules/ui/Inbox/ComparePanel.js +++ b/modules/ui/Inbox/ComparePanel.js @@ -67,12 +67,14 @@ da.ui.ComparePanel.prototype.setFormData = function () { buttonSelect.connect( this, { select: function( item ) { + this.treePanel.showCombinedNode( false ); + this.treePanel.deselectBranches(); if ( item.getData() === 'remote' ) { this.treePanel.selectBranch( 'remote' ); } else if ( item.getData() === 'local' ) { this.treePanel.selectBranch( 'local' ); - } else { - this.treePanel.deselectBranches(); + } else if ( item.getData() === 'merge' ) { + this.treePanel.showCombinedNode( true ); } } } ); diff --git a/modules/ui/Inbox/TreeNode.js b/modules/ui/Inbox/TreeNode.js index d37b94ee..6cd39e68 100644 --- a/modules/ui/Inbox/TreeNode.js +++ b/modules/ui/Inbox/TreeNode.js @@ -3,7 +3,6 @@ da.ui = window.da.ui || {}; da.ui.TreeNode = function ( hash, data ) { this.hash = hash; - console.log( hash, data ); this.nodeData = data; da.ui.TreeNode.super.call( this, {} ); @@ -37,7 +36,7 @@ da.ui.TreeNode.prototype.makeRelevantNode = function () { .attr( 'hash', this.hash ) .attr( 'revisions', this.nodeData.revisions ) .attr( 'parent', this.nodeData.parent ) - .attr( 'diff', this.nodeData.diff ) + .attr( 'diff', this.nodeData.diff ); }; da.ui.TreeNode.prototype.makeLabel = function () { diff --git a/modules/ui/Inbox/TreePanel.js b/modules/ui/Inbox/TreePanel.js index 85bc4cd6..59e835d5 100644 --- a/modules/ui/Inbox/TreePanel.js +++ b/modules/ui/Inbox/TreePanel.js @@ -5,17 +5,25 @@ da.ui.TreePanel = function ( config ) { config = config || {}; this.tree = config.tree || {}; this.nodes = {}; + this.lastParents = { 'local': null, 'remote': null }; da.ui.TreePanel.super.call( this, $.extend( { expanded: false }, config ) ); }; OO.inheritClass( da.ui.TreePanel, OO.ui.PanelLayout ); da.ui.TreePanel.prototype.initialize = function () { + this.lastParents = { 'local': null, 'remote': null }; for ( var hash in this.tree ) { if ( !this.tree.hasOwnProperty( hash ) ) { continue; } var node = this.makeNode( this.tree[ hash ], hash ); + if ( node.getType() === 'local' ) { + this.lastParents.local = node.getHash(); + } + if ( node.getType() === 'remote' ) { + this.lastParents.remote = node.getHash(); + } this.addNode( node ); } @@ -41,13 +49,16 @@ da.ui.TreePanel.prototype.connect = function () { if ( !$relevantNode ) { continue; } - var parent = $relevantNode.attr( 'parent' ); - if ( - parent && - this.nodes.hasOwnProperty( parent ) && - this.nodes[parent].getRelevantNode() - ) { - this.drawConnection( this.nodes[parent].getRelevantNode(), $relevantNode ); + var parent = $relevantNode.attr( 'parent' ), + parents = parent ? parent.split( ',' ) : []; + for ( var i = 0; i < parents.length; i++ ) { + parent = parents[i]; + if ( !parent ) { + continue; + } + if ( this.nodes.hasOwnProperty( parent ) && this.nodes[parent].getRelevantNode() ) { + this.drawConnection( this.nodes[parent].getRelevantNode(), $relevantNode ); + } } } }; @@ -114,6 +125,19 @@ da.ui.TreePanel.prototype.selectBranch = function ( branch ) { this.connect(); }; +da.ui.TreePanel.prototype.showCombinedNode = function ( show ) { + this.deselectBranches( false ); + if ( show ) { + this.addNode( new da.ui.CombinedTreeNode( this.lastParents ) ); + } else { + if ( this.nodes.hasOwnProperty( '-1' ) ) { + this.nodes[ '-1' ].$element.remove(); + delete this.nodes[ '-1' ]; + } + } + this.connect(); +}; + da.ui.TreePanel.prototype.deselectBranches = function ( redraw ) { redraw = typeof redraw === 'undefined' ? true : redraw; this.$element.find( '.da-branch-connector' ).remove(); diff --git a/modules/ui/Inbox/style.less b/modules/ui/Inbox/style.less index a2b24d86..157d9011 100644 --- a/modules/ui/Inbox/style.less +++ b/modules/ui/Inbox/style.less @@ -21,6 +21,10 @@ background-color: #e87b0a; border-radius: 100px; } + &.da-compare-node-graph-combined { + background-color: #2956e8; + border-radius: 100px; + } } .da-compare-node-label { @@ -60,3 +64,25 @@ } } + +#da-specialinbox-compare-diff { + width: 100%; + background-color: #eeeeee; + padding: 20px; + .da-diff-change { + .da-diff-delete { + margin: 0; + background-color: #f38993; + &:before { + content: '-'; + } + } + .da-diff-add { + margin: 0; + background-color: #a9e5a9; + &:before { + content: '+'; + } + } + } +}