package votorola.s.gwt.mediawiki; // Copyright 2011-2013, Michael Allan. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Votorola Software"), to deal in the Votorola Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of the Votorola Software, and to permit persons to whom the Votorola Software is furnished to do so, subject to the following conditions: The preceding copyright notice and this permission notice shall be included in all copies or substantial portions of the Votorola Software. THE VOTOROLA SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE VOTOROLA SOFTWARE OR THE USE OR OTHER DEALINGS IN THE VOTOROLA SOFTWARE. import com.google.gwt.core.client.*; import com.google.gwt.dom.client.*; import com.google.gwt.regexp.shared.*; import com.google.gwt.user.client.Window; import java.util.*; import votorola.a.count.*; import votorola.a.count.gwt.*; import votorola.a.web.gwt.*; import votorola.g.lang.*; import votorola.g.web.gwt.*; import votorola.g.web.gwt.event.*; import votorola.s.gwt.stage.*; import votorola.s.gwt.stage.vote.*; /** A view of difference shadows for MediaWiki draft pages. It * shows a single difference at a time, whichever is currently lit by the {@linkplain * ShadowLight shadow light}. The view includes a {@linkplain #topBox top bridge box} * (t) composed of a {@linkplain BridgeBox#bridgeLink() bridge link} ] [ and a * {@linkplain TopBox#segCounter() segment counter}.
  *
  *                              (t)-- ] [  +-----------+  +------------+  +--
  *                                \        | user page |  | discussion |  |
  *                                 \   +---+           +--+------------+--+--
  *        +------------------------ \  |
  *        |                          \
  *        |                         |  3
  *        |                         |  v
  *        |        w i k i          |                 (showing Monobook skin,
  *        |                         |  |                 Vector is similar)
  *        |        l o g o          |  |
  *        |                         |  |
  *        |       i m a g e         |  |
  *        |                         |  |
  *        |                         |  |
  *        |                         |  |
  *        +-------------------------+  |
  *                                     |
  *                                    ]|[          ###########
  *                                     |
  *                                     |
  *                                     |        (s)
  *                                     |
  *                                     |
  *                                    ]|[         ###########################
  *                                     |   ##################################
  *                                     |   ##############
  *                                     |
  *                                    ]|[                        ########
  *                                     |
  *                                     |
* * The shadow (s) is projected underneath the text of the author's draft in those regions * (segments ###) where it differs from the other person's draft. Each shadow segment is * accompanied by a {@linkplain BridgeBox#bridgeLink() bridge link} ] [ targeting that * particular segment of the difference. * *

Page parameters

* *

The following parameters are defined for a shadowed page in addition to those * already defined by MediaWiki.

* * * * * * * * * * * * * * * * * * * * * *
KeyValueDefault
repThe debug {@linkplain #rep() reporting threshold}.0
rep_diff_userFilters difference related debug reports by the username of the other * drafter.Null, optional item.
*/ final class DifferenceShadowsV { /** Constructs a DifferenceShadowsV for {@linkplain #init() init} to finish. It will * co-exist permanently with the stage. */ DifferenceShadowsV( DifferenceShadows _shadows, final Stage stage ) { shadows = _shadows; topBox = TopBox.newTopBox( shadows, stage ); } /** Completes the construction of the DifferenceShadowsV. Call once only. * * @throws Warning if a failure is detected that ought to be reported to the * user. */ void init() throws Warning { final Document doc = Document.get(); final Element topNodeOrNull = doc.getElementById( "firstHeading" ); if( topNodeOrNull == null ) { if( rep > 0 ) report( "unable to add topmost bridge link, no element id='firstHeading'", 1 ); // any appendMismatchLinksTo(topBox) are going to be invisible, but that // cannot be helped } else insertLink( topBox, topNodeOrNull ); // Find a "content leader" node above the editable content. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Node contentLeader = doc.getElementById( "jump-to-nav" ); if( contentLeader == null ) { contentLeader = topNodeOrNull; if( contentLeader == null ) { contentLeader = doc.getElementById( "content" ); // fail-safe, higher in doc if( contentLeader == null ) { throw new Warning( "unable to show difference shadows, no content leader node" ); } } } else contentLeader = contentLeader.getNextSibling(); // Concatenate the searchable text of the document into docC string and docN array. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final DifferenceParse p = new DifferenceParse(); for( Node node = contentLeader;; ) { node = NodeX.nextNode( node, /*deeply*/true ); if( node == null ) break; if( Element.is( node )) // try to detect end of editable content { final Element el = node.cast(); final String cl = el.getClassName(); if( "printfooter".equals(cl) || "visualClear".equals(cl) || "column-one".equals(el.getId()) ) break; continue; } final short nodeType = node.getNodeType(); if( nodeType < 3/*Node.TEXT_NODE*/ || nodeType > 4/*Node.CDATA_SECTION_NODE*/ ) continue; final Text text = node.cast(); text._setInt( "offset", p.docC.length() ); p.docN.add( text ); p.docC.append( text.getData() ); } if( p.docC.length() == 0 ) // probably impossible { throw new Warning( "unable to show difference shadows, page has no text content" ); } // Mark up the page with shadows. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if( rep > 0 ) report( "document text: [" + p.docC.toString() + "]", 2 ); shadows.run( new LightableDifference.Runner() { public void run( final ShadowedDiff diff ) { tryMark( p, diff ); } }); } // ------------------------------------------------------------------------------------ /** Shows a debug report to the user. The report is shown as {@linkplain * Stage#addWarning(String) stage warnings} when the view runs in {@linkplain * GWT#isProdMode() production mode}, otherwise it is printed to the console. * * @param level the detail level of the message, 1 or higher. * * @see #rep() */ void report( final String message, final int level ) { if( level > rep ) return; final StringBuilder b = GWTX.stringBuilderClear(); b.append( message ); b.append( "\n(rep=" ); b.append( rep ); b.append( " \u2265 " ); // greater than or equal b.append( level ); b.append( ')' ); final String report = b.toString(); if( GWT.isProdMode() ) Stage.i().addWarning( report ); else { System.out.println( report ); System.out.println(); } } /** The debug reporting threshold as requested by the rep * page parameter. A level of zero (0) disables reporting entirely. Levels of 1 * or higher enable progressively more detailed messages to be reported. * * @see #report(String,int) */ int rep() { return rep; } private final int rep; { final String repString = Window.Location.getParameter( "rep" ); if( repString == null ) rep = 0; else rep = Integer.parseInt( repString ); } /** Shows a {@linkplain #report(String,int) debug report} to the user that may be * filtered by difference properties. * * @param level the detail level of the message, 1 or higher. * @param diff the argument for any rep_diff_user * filter. */ void report( final String message, final int level, final ShadowedDiff diff ) { final String username = diff._getString( topsSegments(diff)? "bUsername": "aUsername" ); if( rep_diff_user != null && !rep_diff_user.equals( username )) return; // filter out final StringBuilder b = new StringBuilder(); b.append( "diff " ); b.append( diff.key() ); b.append( ", " ); b.append( username ); b.append( ", " ); b.append( message ); report( b.toString(), level ); } private final String rep_diff_user = Window.Location.getParameter( "rep_diff_user" ); /** The source URL of the image for links to difference segments. */ public static final String SRC_DIFF; /** The source URL of the image for disabled links to difference segments. */ public static final String SRC_DIFF_DISABLED; /** The source URL of the image for links to unmatched difference segments. */ private static final String SRC_DIFF_ERROR; static { final String context = App.i().staticContextLocation(); SRC_DIFF = context + "/diff/diff.png"; SRC_DIFF_DISABLED = context + "/mediawiki/diffDisabled.png"; SRC_DIFF_ERROR = context + "/mediawiki/diffError.png"; } /** The topmost bridge box (t). */ TopBox topBox() {return topBox; } private final TopBox topBox; // ==================================================================================== /** A box of controls (t or s) that is shown for the shadowed * difference currently lit by the {@linkplain ShadowLight shadow light}. */ static class BridgeBox extends Element { protected BridgeBox() {} // "precisely one constructor... protected, empty, and no-argument" /** Constructs a bridge box. * * @param href the bridge link's 'href' attribute, if it has one. */ static final BridgeBox newBridgeBox( final Document doc, final String extraClassName, final String href ) { final Element box = doc.createSpanElement(); box.setClassName( "BridgeBox" ); if( extraClassName != null ) box.addClassName( extraClassName ); final AnchorElement link = newBridgeLink( doc, SRC_DIFF, href ); box.appendChild( link ); return box.cast(); } /** The link to the {@linkplain votorola.s.wic.diff.WP_D difference bridge}. */ final AnchorElement bridgeLink() { return AnchorElement.as( Element.as( getFirstChild() )); } } // ==================================================================================== /** The topmost bridge box (t). * * @see #topBox() */ static final class TopBox extends BridgeBox implements PropertyChangeHandler { protected TopBox() {} // "precisely one constructor... protected, empty, and no-argument" /** Constructs the top box. */ static final TopBox newTopBox( final DifferenceShadows shadows, final Stage stage ) { final Document doc = Document.get(); final TopBox box = newBridgeBox( doc, "top", /*relink sets href*/null ).cast(); box._set( "shadows", shadows ); // View. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - box.appendChild( doc.createBRElement() ); final Element span = doc.createSpanElement(); box.appendChild( span ); { final Text segCounter = doc.createTextNode( "" ); // init by relink box._set( "segCounter", segCounter ); span.appendChild( segCounter ); } final ImageElement segCounterArrow = doc.createImageElement(); box._set( "segCounterArrow", segCounterArrow ); segCounterArrow.setSrc( App.i().staticContextLocation() + "/mediawiki/segCounterArrow.png" ); segCounterArrow.setClassName( "segCounterArrow" ); box.appendChild( doc.createBRElement() ); box.appendChild( segCounterArrow ); // Controller. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - GWTX.i().bus().addHandlerToSource( PropertyChange.TYPE, /*source*/stage, box ); // no need to unregister, registry does not outlive this listener box.relink( shadows, stage ); // init return box; } private void relink( final DifferenceShadows shadows, final Stage stage ) { final AnchorElement bridgeLink = bridgeLink(); final ShadowedDiff diff = shadows.diffFor( stage.getDifference() ); if( diff == null ) { bridgeLink.getStyle().setCursor( Style.Cursor.DEFAULT ); /* arrow as opposed to pointer (Firefox 3.6), because no actual href */ bridgeLink.setHref( (String)null ); /* both null and "" actually target '.' in Firefox (3.6). Maybe FIX by entirely deleting "href" property. */ } else { bridgeLink.getStyle().clearCursor(); // to default bridgeLink.setHref( diff.bridgeLoc() ); } } /** The text node that shows the number of segments in the shadowed difference. */ Text segCounter() { return _get( "segCounter" ); } /** The arrow image beneath the counter. */ ImageElement segCounterArrow() { return _get( "segCounterArrow" ); } private native DifferenceShadows shadows() /*-{ return this.shadows; }-*/; // - P r o p e r t y - C h a n g e - H a n d l e r -------------------------------- public void onPropertyChange( final PropertyChange e ) { if( e.propertyName().equals( "difference" )) relink( shadows(), (Stage)e.getSource() ); } } // ==================================================================================== /** Thrown when a failure is detected that ought to be shown to the user. */ static final class Warning extends Exception { public Warning( String _message ) { super( _message ); } } //// P r i v a t e /////////////////////////////////////////////////////////////////////// /** Appends links for mismatched segments to the bridge box, one for each listed in * p.mismatchedSegLocs. Clears the list. */ private void appendMismatchLinksTo( final BridgeBox bridgeBox, final String relClass, final DifferenceParse p, final Document doc ) { final int uN = p.mismatchedSegLocs.size(); if( uN == 0 ) return; final Element y; if( bridgeBox == topBox ) // then wrap links for relational styling { y = doc.createSpanElement(); y.setClassName( "orphanMismatch" ); y.addClassName( relClass ); bridgeBox.appendChild( y ); } else y = bridgeBox; boolean isFirst = true; for( final String loc: p.mismatchedSegLocs ) { y.appendChild( doc.createBRElement() ); final AnchorElement link = newBridgeLink( doc, SRC_DIFF_ERROR, loc ); if( isFirst ) link.setClassName( "leadError" ); y.appendChild( link ); isFirst = false; } p.mismatchedSegLocs.clear(); } /** Inserts a link or link container somewhere before the specified node. Takes care * in doing so not to nest links and confuse the browser (Firefox). */ private void insertLink( final Element linkContainer, Node node ) { Node parent; for(;; node = parent ) // avoid nesting, escape from any link we're inside of { parent = node.getParentNode(); if( !Element.is( parent )) continue; // mystery node, escape from it final Element firstNonShadowAncestor; // find it: for( Element ancestor = Element.as( parent );; ) { if( !ancestor._hasProperty( "isShadow" )) // found it { firstNonShadowAncestor = ancestor; break; } // else was injected by this view and might be descendant of link, so keep climbing: ancestor = NodeX.nextAncestorElement( ancestor ); } if( !"a".equals( firstNonShadowAncestor.getNodeName().toLowerCase() )) break; parent = firstNonShadowAncestor; // skip any intervening shadow nodes } parent.insertBefore( linkContainer, node ); } /** Marks up the page with the shadow of the specified difference in the form of span * elements. Each span element has an extended boolean property 'isShadow' that is * always set to true, thus identifying the element as an injected shadow. Also sets * the value of diff.{@linkplain ShadowedDiff#segmentCount() segmentCount}. * * @throws Warning if the marking fails in a detectable way. */ private void mark( final DifferenceParse p, final ShadowedDiff diff ) throws Warning { p.setDiff( diff ); final boolean topsSeg = topsSegments( diff ); // Ensure the two drafts actually differ, and skip the two header lines. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final String diffText = p.diffText; if( NO_DIFF_PATTERN.exec(diffText) != null ) return; skipDiffSegment( p, '-', /*required*/true ); // --- header line skipDiffSegment( p, '+', /*required*/true ); // +++ " // Set up to loop through the hunks of the diff. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final SegmentPatternBuilder bSP = new SegmentPatternBuilder( DifferenceShadowsV.this, p ); final Document doc = Document.get(); int docCSearchStart = 0; // always points to the end bound of the last match BridgeBox lastBridgeBox = topBox; final String relClass = diff.stageRelClass(); // For each hunk and segment (hunk.seg) of the diff: // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final int dN = diffText.length(); int segmentCount = 0; // in diff int segOrdinalH = 0; // in hunk only diff: for( int hunkCount = 0; p.d < dN; ++segOrdinalH ) { if( skipDiffSegment( p, '@', /*required*/hunkCount == 0 )) // @@ hunk header { ++hunkCount; segOrdinalH = 1; } ++segmentCount; final String segName = hunkCount + "." + segOrdinalH; final String segLoc = diff.bridgeLoc() + "#_" + segName; // Parse the segment out of the diff, and translate it to a search pattern. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final RegExp pattern; { final String contextAbove = bSP.nextPattern( ' ' ); final String seg; if( topsSeg ) { seg = bSP.nextPattern( '-' ); skipDiffSegment( p, '+' ); // if any } else { skipDiffSegment( p, '-' ); // if any seg = bSP.nextPattern( '+' ); } final String contextBelow = bSP.nextPattern( ' ' ); final StringBuilder b = GWTX.stringBuilderClear(); b.append( contextAbove ); b.append( '(' ); b.append( seg ); b.append( ')' ); if( contextBelow.length() > 0 ) { b.append( "(?=" ); b.append( contextBelow ); b.append( ')' ); } pattern = RegExp.compile( b.toString(), "g" ); } // Search for the segment pattern in the document text. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - pattern.setLastIndex( docCSearchStart ); int docC0; // offset of match (first char) final int docCEnd; // end bound of match (last char + 1) { final MatchResult match = pattern.exec( p.docC.toString() ); if( match == null ) { if( rep > 0 ) report( "hunk.seg " + segName + ", mismatch " + docCSearchStart + "... :\n\n" + pattern, 1, diff ); p.mismatchedSegLocs.add( segLoc ); continue diff; } docCEnd = pattern.getLastIndex(); docC0 = docCEnd - match.getGroup(1).length(); docCSearchStart = docCEnd; } final boolean isDeletion; // whether seg is pure deletion from this text if( docC0 == docCEnd ) { isDeletion = true; if( rep > 0 ) report( "hunk.seg " + segName + ", match delete between (" + (docC0-1) + ", " + docC0 + "):\n\n" + pattern, 1, diff ); if( docC0 == p.docC.length() ) --docC0; // entire doc deleted, fudge to avoid edge case } else { isDeletion = false; if( rep > 0 ) report( "hunk.seg " + segName + ", match " + docC0 + ".." + (docCEnd-1) + ":\n\n" + pattern, 1, diff ); } // Decorate any intervening mismatches. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - appendMismatchLinksTo( lastBridgeBox, relClass, p, doc ); // Locate first node of matched text. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - int n; { final JavaScriptObject o = JavaScriptObject.createObject(); o._setInt( "offset", docC0 ); n = Collections.binarySearch( p.docN, o, DifferenceParse.DOC_N_COMPARATOR ); } if( n < 0 ) { final int insertionPoint = -n - 1; n = insertionPoint - 1; // The exactly matching node would fall between N and M, so the exactly // matching character falls within N. That's where to start decorating. } Text node = p.docN.get( n ); { final int headRoom = docC0 - node._getInt("offset"); if( headRoom > 0 ) // split off the head { final Text tail = node.splitText( headRoom ); // head to be left unmarked tail._setInt( "offset", docC0 ); node = tail; ++n; p.docN.add( n, tail ); // insert tail after head } } // If it's a pure deletion, mark it up. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if( isDeletion ) // then no text to shadow, so only markup is link { lastBridgeBox = BridgeBox.newBridgeBox( doc, relClass, segLoc ); insertLink( lastBridgeBox, node ); continue diff; } // Else for each node of the matched text: // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final Text firstNode = node; for( int remainder = docCEnd - docC0;; ) { final int nodeLength = node.getLength(); Text nextNode = null; // till proven otherwise if( remainder < nodeLength ) // split off the tail { final Text tail = node.splitText( remainder ); // keeping head to mark up tail._setInt( "offset", docCEnd ); ++n; p.docN.add( n, tail ); // insert tail after head } else if( remainder > nodeLength ) { ++n; // there must be a next node nextNode = p.docN.get( n ); } // else last node to mark up, and fully to the tail (it was not split) // Mark up the node. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final Element span = doc.createSpanElement(); span.setClassName( "voShadow" ); span.addClassName( relClass ); span._setBoolean( "isShadow", true ); node.getParentNode().insertBefore( span, node ); span.appendChild( node ); if( node == firstNode ) // absolute == test OK because of "firstNode = node" above { lastBridgeBox = BridgeBox.newBridgeBox( doc, relClass, segLoc ); insertLink( lastBridgeBox, span ); } if( nextNode == null ) break; node = nextNode; remainder = docCEnd - node._getInt("offset"); } } diff._setInt( "segmentCount", segmentCount ); // Decorate any trailing mismatches. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - appendMismatchLinksTo( lastBridgeBox, relClass, p, doc ); } private void tryMark( DifferenceParse _p, ShadowedDiff _diff ) { try { mark( _p, _diff ); } catch( final Warning warning ) { Stage.i().addWarning( warning.toString() ); } } /** Constructs a link to the difference bridge in which the first child is the link * image. * * @param href the link's 'href' attribute, if it has one. */ private static final AnchorElement newBridgeLink( final Document doc, final String src, final String href ) { final AnchorElement link = doc.createAnchorElement(); if( href != null ) link.setHref( href ); final ImageElement img = doc.createImageElement(); img.setSrc( src ); link.appendChild( img ); return link; } /** The pattern of a negative 'diff' result, where the two files are identical. */ private static final RegExp NO_DIFF_PATTERN = RegExp.compile( "^Files .+ and .+ are identical" ); // cf. a.diff.DiffCache.NO_DIFF_PATTERN private final DifferenceShadows shadows; private boolean skipDiffSegment( final DifferenceParse p, final char prefix ) throws Warning { return skipDiffSegment( p, prefix, /*required*/false ); } /** Skips to the next segment of the current diff, passing all contiguous lines that * begin with the specified prefix. Pointer p.d is left at the start of the next * segment, or at p.diffText.length if there is none. * * @param prefix the line prefix of the diff segment to skip. * @param isSegmentRequired true if the segment ought always to be there, false * if it might be missing. * * @return true if anything was skipped, false otherwise. * * @throws Warning if the current position is not a line start; or if the * expected prefix is not found and isSegmentRequired is true. */ private boolean skipDiffSegment( final DifferenceParse p, final char prefix, final boolean isSegmentRequired ) throws Warning { if( rep > 0 ) report( p.d + " --- skipDiffSegment '" + prefix + "'", 3, p.diff ); final String text = p.diffText; final int dN = text.length(); if( p.d >= dN ) // end of text { if( !isSegmentRequired ) return false; throw new Warning( "Expected '" + prefix + "' but found end of text: " + p.positionMessage() ); } boolean isLineStart = p.d == 0 || text.charAt(p.d-1) == '\n'; if( !isLineStart ) { throw new Warning( "Expected '" + prefix + "' line start, but not found: " + p.positionMessage() ); } char ch = text.charAt( p.d ); if( ch != prefix ) { if( !isSegmentRequired ) return false; throw new Warning( "Expected '" + prefix + "' but found '" + ch + "': " + p.positionMessage() ); } ++p.d; isLineStart = false; for(; p.d < dN ; ++p.d ) { ch = text.charAt( p.d ); if( isLineStart && ch != prefix ) break; // end of segment isLineStart = ch == '\n'; } return true; } /** Answers whether this draft's lines are first (-) in the difference segments (as * opposed to second +), or eqivalently whether this draft page is the 'a' draft. */ private static boolean topsSegments( final ShadowedDiff diff ) { assert diff.text() != null: "diff belongs to DifferenceShadows"; return "a".equals( diff.selectand() ); // because this draft is always selectand } } // NOTES // // [Fla06] David Flanagan, 2006. JavaScript: The Definitive Guide. 5th ed. O'Reilly. // Sebastapol, California. // // [GH] Non-greedy repetition is actually greedy at the head of the pattern, because the // matcher is required to return the first possible match. [Fla06p203]