package votorola.s.wic.diff; // Copyright 2010-2013, Michael Allan, Christian Weilbach. 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 java.io.*; import java.net.*; import java.nio.charset.*; import java.sql.SQLException; import java.util.*; import java.util.logging.*; import votorola.g.logging.*; import java.util.regex.*; import javax.script.ScriptException; import javax.xml.stream.*; import org.apache.wicket.Component; import org.apache.wicket.AttributeModifier; import org.apache.wicket.RestartResponseException; import org.apache.wicket.behavior.AttributeAppender; import org.apache.wicket.markup.html.IHeaderResponse; import org.apache.wicket.markup.html.WebMarkupContainer; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.form.Button; import org.apache.wicket.markup.html.form.CheckBox; import org.apache.wicket.markup.html.form.StatelessForm; import org.apache.wicket.markup.html.link.ExternalLink; import org.apache.wicket.markup.html.panel.Fragment; import org.apache.wicket.markup.repeater.RepeatingView; import org.apache.wicket.model.Model; import org.apache.wicket.request.mapper.parameter.PageParameters; import votorola.a.*; import votorola.a.count.*; import votorola.a.diff.*; import votorola.a.position.*; import votorola.a.web.wic.*; import votorola.a.web.wic.authen.*; import votorola.g.*; import votorola.g.hold.*; import votorola.g.io.*; import votorola.g.lang.*; import votorola.g.locale.*; import votorola.g.net.*; import votorola.g.web.wic.*; import votorola.s.wap.*; import static votorola.g.MediaWiki.API_POST_CHARSET; import static votorola.s.gwt.stage.vote.LightableDifference.REL_CANDIDATE; import static votorola.s.gwt.stage.vote.LightableDifference.REL_CO; import static votorola.s.gwt.stage.vote.LightableDifference.REL_TIGHT_CYCLE; import static votorola.s.gwt.stage.vote.LightableDifference.REL_UNKNOWN; import static votorola.s.gwt.stage.vote.LightableDifference.REL_VOTER; /** The Wicket interface of the difference bridge, which includes a view of the difference * between a pair of draft revisions. Two major views (stage and difference) divide the * overall layout and interconnect via specialized {@linkplain votorola.s.gwt.wic.DIn * overlay graphics} (not shown):
  *
  *    +--------------------------------------+
  *    |               stage                  |
  *    +--------------------------------------+
  *    |                                      |
  *    |                                      |
  *    |                                      |
  *    |                                      |
  *    |             difference               |
  *    |                                      |
  *    |                                      |
  *    |                                      |
  *    |                                      |
  *    |                                      |
  *    +--------------------------------------+
* *

At the top is a Crossforum Theatre {@linkplain votorola.s.gwt.stage.StageV stage * view} implented in GWT. The bulk of the page is occupied by a static HTML view of a * difference between two drafts, together with controls for selectively patching between * them. Only drafts published as MediaWiki pages are supported. The pair is specified * by one or more query parameters which together comprise a "pair specifier". Several * types of pair specifier are supported:

* *

Difference key

* *

Here is an example of a request using a difference key:

* *
http://reluk.ca:8080/v/w/D?k=3812.3556-3242.3004!1
* * * * * * * * * * * *
KeyValue
kThe {@linkplain DiffKey difference key}.
* *

Convenience redirects

* *

Choose one of the following. Each redirects to a difference view in normal * voter-candidate order where applicable, or lexical order otherwise.

* * * * * * * * * * * * * * * * *
KeyValue
alterAuthor
poll
The name of an author and a poll in the local wiki. The requester is * redirected to the difference between the latest revisions of that author's * draft and the authenticated user's draft. Parameter 's' is interpreted as * though the user's revision will come first (a) and the other second (b), * though the actual placement may be the opposite.
aAuthor
bAuthor
poll
The names of two authors and a poll. The requester is redirected to the * difference between the latest revisions of their position drafts.
* *

Legacy pair specifier

* *

This obsolete, parsed form of a difference key specifier (k) is provided for the * service of old links. It comprises up to 4 query parameters: 'a', 'aR', 'b', and * 'bR'. For example: (fails because it cannot accommodate the revision series !1)

* *
http://reluk.ca:8080/v/w/D?a=3812&b=3242&aR=3556&bR=3004 (fails)
* * * * * * * * * * * * * * * * * * * * * * * * * * *
KeyValue
aThe first component in the revision path of the a-draft.
aRThe second component in the revision path of the a-draft, if any.
bThe first component in the revision path of the b-draft.
bRThe second component in the revision path of the b-draft, if any.
* *

Other query parameters

* * * * * * * * * * * * * * *
KeyValueDefault
sThe selectand specified as diff ordinal 'a' or 'b'. Use 's' or 's=b' to * select the second revison of the pair. The choice affects links and other * controls associated with the difference view and the stage.'a', or the first revision.
* * @see Category:Draft * @see Category:Draft pointer * @see WP_D.html */ @ThreadRestricted("wicket") @org.apache.wicket.devutils.stateless.StatelessComponent public final class WP_D extends VPageHTML { // Planned URL mapping strategy: // // D -> WP_D, lt=1 (these are defaults) // D?f=a1 -> WP_D, lt=1 // D?f=a2 -> WP_D, lt=2 // D?f=b2 -> WP_DiffBridgeB, lt=2 // // Where form parameter (f) is unpacked into page layout and line transformer version. // The URL could be squeezed further at any time by a path mounting, like D/a1, etc. /** Constructs a WP_D. */ public WP_D( final PageParameters pP ) throws IOException, SQLException, XMLStreamException { // bookmarkable page iff constructor public & (default|PageParameter) super( pP ); // VOWicket.get().vsRun().voteServer().diffCache().LINE_TRANSFORMER.test(); // TEST final boolean bToSelect; { final String s = PageParametersX.getString( pP, "s" ); if( s == null || "a".equals(s) ) bToSelect = false; else if( "".equals(s) || "b".equals(s) ) bToSelect = true; else { VSession.get().error( "bad value for query parameter 's': " + s ); throw new RestartResponseException( new WP_Message() ); } } // Redirect if requested // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final VRequestCycle cycle = VRequestCycle.get(); final VOWicket app = VOWicket.get(); final VSession.User userOrNull = VSession.get().user(); final VoteServer.Run vsRun = app.vsRun(); final VoteServer vS = vsRun.voteServer(); final PollwikiVS wiki = vS.pollwiki(); try { // alterAuthor // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` final String alterAuthor = stringNonEmpty( pP, "alterAuthor" ); if( alterAuthor != null ) { pP.remove( "alterAuthor" ); final String pollName = stringRequired( pP, "poll" ); pP.remove( "poll" ); if( userOrNull == null ) { pP.set( "returnClass", getClass().getName() ); throw new RestartResponseException( app.authenticator().newLoginPage( pP )); } final String username = userOrNull.username(); if( username.equals( alterAuthor )) { VSession.get().info( "attempt to diff latest of same author: " + alterAuthor ); throw new RestartResponseException( new WP_Message() ); } final CountSource1 countSource = new CountSource1( vsRun ); DraftPair pair = DraftPair.newDraftPair( wiki.positionPageName(alterAuthor,pollName), wiki.positionPageName(username,pollName), vsRun, countSource ); final Count count = countSource.count( pollName ); if( count != null ) { final CountNodeW bNode = count.countTablePV().get( userOrNull.email() ); if( bNode != null ) { if( pair.aCore().person().email().equals( bNode.getCandidateEmail() )) { pair = pair.newReversePair(); } } } if( pair.bCore().person().equals( userOrNull )) // then reverse sense of selectand s { if( bToSelect ) pP.remove( "s" ); else pP.set( "s", "" ); } pP.set( "k", pair.diffKeyParse().key() ); throw new RedirectException( cycle.uriFor(WP_D.class,pP).toString(), 303 ); } // aAuthor, bAuthor, poll // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` final String aAuthor = stringNonEmpty( pP, "aAuthor" ); if( aAuthor != null ) { pP.remove( "aAuthor" ); final String bAuthor = stringRequired( pP, "bAuthor" ); pP.remove( "bAuthor" ); if( aAuthor.equals( bAuthor )) { VSession.get().info( "attempt to diff latest of same author: " + aAuthor ); throw new RestartResponseException( new WP_Message() ); } final String pollName = stringRequired( pP, "poll" ); pP.remove( "poll" ); DraftPair pair = DraftPair.newDraftPair( wiki.positionPageName(aAuthor,pollName), wiki.positionPageName(bAuthor,pollName), vsRun, /*countSource*/null ); if( !pair.aCore().person().username().equals( aAuthor )) { pair = pair.newReversePair(); } pP.set( "k", pair.diffKeyParse().key() ); throw new RedirectException( cycle.uriFor(WP_D.class,pP).toString(), 303 ); } // ` ` ` // voterDraft - removed per note [1] } catch( final IOException x ) { if( !(x instanceof UserInformative )) throw x; VSession.get().error( ThrowableX.toStringExpanded( x )); throw new RestartResponseException( new WP_Message() ); } // Query wiki for drafts // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { final String k = stringNonEmpty( pP, "k" ); final DiffKeyParse kP; try { if( k != null ) kP = new DiffKeyParse( k ); else { kP = new DiffKeyParse( legacyRev(pP,"a"), legacyRevOptional(pP,"aR"), legacyRev(pP,"b"), legacyRevOptional(pP,"bR") ); } } catch( final DiffKeyParse.MalformedKey x ) { VSession.get().error( x.toString() ); throw new RestartResponseException( new WP_Message() ); } try{ pair = DraftPair.newDraftPair( kP, wiki ); } catch( final IOException x ) { if( !( x instanceof UserInformative )) throw x; VSession.get().error( ThrowableX.toStringExpanded( x )); throw new RestartResponseException( new WP_Message() ); } } // Redirect if pair misordered, per DiffKey normal ordering, R/C/U rules // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final CoreRevision aCore = pair.aCore(); final CoreRevision bCore = pair.bCore(); final String aUsername = aCore.person().username(); final String bUsername = bCore.person().username(); char rel = REL_UNKNOWN; // style symbol for cast relation of a to b { final DiffKeyParse kP = pair.diffKeyParse(); boolean isOrdered = true; // safer assumption to prevent redirect cycle order: if( aUsername.equals( bUsername )) // same author, order by revision path R1 { final List aPath = kP.aPath(); final List bPath = kP.bPath(); int r = 0; for( ;; ) { final int comparison = aPath.get(r).compareTo( bPath.get(r) ); if( comparison > 0 ) break; // R1 else if( comparison < 0 ) { isOrdered = false; // R1 break; } ++r; if( r == aPath.size() ) { if( r == bPath.size() ) { VSession.get().info( "attempt to diff same draft: " + aCore ); throw new RestartResponseException( new WP_Message() ); } isOrdered = false; // R2 break; } if( r == bPath.size() ) break; // R2 } } else // two authors { final String pollName = aCore.pollName(); if( pollName.equals( bCore.pollName() )) // same poll { final Count count; try{ count = vsRun.scopePoll().ensurePoll(pollName).countToReportT(); } catch( ScriptException x ) { throw new RuntimeException( x ); } if( count != null ) { final CountTable.PollView countTablePV = count.countTablePV(); final CountNodeW aNode = countTablePV.get( aCore.person().email() ); if( aNode != null ) { final CountNodeW bNode = countTablePV.get( bCore.person().email() ); if( bNode != null ) { final String aCanEmail = aNode.getCandidateEmail(); final String bCanEmail = bNode.getCandidateEmail(); if( bNode.email().equals( aCanEmail )) { // if( aNode.holdVolume() == 0 ) /// (a) with impersonal nodes, now invalid cast-relation test, so: if( !aNode.isBaseCandidate() ) { rel = bToSelect? REL_VOTER: REL_CANDIDATE; // C2 break order; } // assert bNode.holdVolume() != 0; // but (a), so: assert bNode.isBaseCandidate(); // must be co-base if( aNode.email().equals( bCanEmail )) { rel = REL_TIGHT_CYCLE; // C3, U1 or U2 isOrdered = DiffKey.isDartOrdered( aNode, aUsername, bNode, bUsername ); } else rel = REL_CO; // C1 break order; } if( aNode.email().equals( bCanEmail )) { // if( bNode.holdVolume() == 0 ) // but (a), so: if( !bNode.isBaseCandidate() ) { rel = bToSelect? REL_CANDIDATE: REL_VOTER; // C2 } else rel = REL_CO; // C1 co-base, not tight, caught earlier isOrdered = false; break order; } if( aCanEmail != null && aCanEmail.equals(bCanEmail) // co-voter // || aNode.holdVolume() > 0 && bNode.holdVolume() > 0 ) /// but (a), so: || aNode.isBaseCandidate() && bNode.isBaseCandidate() ) // co-base { rel = REL_CO; // C3, U1 or U2 isOrdered = DiffKey.isDartOrdered( aNode, aUsername, bNode, bUsername ); break order; } } } } } isOrdered = DiffKey.isLexicallyOrdered( aUsername, bUsername ); // U1 or U2 } if( !isOrdered ) { pP.set( "k", DiffKey.newReverseKey(kP.key()) ); if( bToSelect ) pP.remove( "s" ); else pP.set( "s", "" ); throw new RedirectException( cycle.uriFor(WP_D.class,pP).toString(), 303 ); } } // Crossforum Theatre stage // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { final StringBuilder b = WC_Stage.appendLeader( userOrNull, vS, cycle ); final String selectand; final CoreRevision anchorCore; final CoreRevision otherCore; if( bToSelect ) { selectand = "b"; anchorCore = bCore; otherCore = aCore; } else { selectand = "a"; anchorCore = aCore; otherCore = bCore; } // s_gwt_stage // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` b.append( "_temp = function()" + "{" + "var fThis = arguments.callee;" + "if( fThis.fWrapped ) fThis.fWrapped();" // call admin's own config, if any + "s_gwt_stage_link_NominalDifferenceTargeter_setEnabled();" + "s_gwt_stage_vote_CountNodeV_setDeselectionGuard( 'Lax' );" // Allow click to deselect any node in vote track. With default actor // set, 'Default' would be same as 'Lax' but slightly slower. + "s_gwt_stage_vote_DifferenceLight_setScene( '" ); b.append( otherCore.person().username() ).append( "', '" ); b.append( rel ).append( "' );" + "};" + "_temp.fWrapped = voGWTConfig.s_gwt_stage;" + "voGWTConfig.s_gwt_stage = _temp;" ); // s_gwt_stage_Stage_init, per WC_Stage below // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` b.append( "voGWTConfig.s_gwt_stage_Stage_init = function()" + "{" + "s_gwt_stage_Stage_setDefaultActorName( '" ); b.append( anchorCore.person().username() ).append( "' );" + "s_gwt_stage_Stage_setDefaultDifference(" + "{" + "key:'" ).append( pair.diffKeyParse().key() ).append( "'," + "selectand:'" ).append( selectand ).append( "'" + "});" + "s_gwt_stage_Stage_setDefaultPollName( '" ); b.append( anchorCore.pollName() ).append( "' );" + "};" ); // ` ` ` add( new WC_Stage( "stage", "votorola.s.gwt.wic.DIn", b, cycle )); } // RENDER VIEW // = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = final BundleFormatter bunW = cycle.bunW(); { CoreRevision d = aCore; if( d.person().equals( userOrNull )) userCoreOrNull = d; else { d = bCore; if( d.person().equals( userOrNull )) userCoreOrNull = d; } if( bCore.person().equals( aCore.person() )) { patchBar = bunW.l( "s.wic.diff.WP_D.patchBar.sameUser" ); } else if( userOrNull == null ) patchBar = PATCH_BAR_LOGGED_OUT; else if( userCoreOrNull == null ) patchBar = PATCH_BAR_NON_AUTHOR; else if( userCoreOrNull instanceof ComponentPipeRevision ) { patchBar = bunW.l( "s.wic.diff.WP_D.patchBar.componentPipe" ); } } setPageIcon( cycle.staticContextLocation() + "/diff/diff.png" ); add( new Label( "title", bunW.l( "s.wic.diff.WP_D.title", pair.aUserMnemonic(), pair.bUserMnemonic() ))); add( new WC_NavigationHead( "navHead", WP_D.this, cycle )); // Diff // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final DiffForm form; final String patchButtonValue = bunW.l( "s.wic.diff.WP_D.patchButtonValue" ); final String patchButtonTitle = bunW.l( "s.wic.diff.WP_D.patchButtonTitle" ); diffFile = vS.diffCache().diffFile( pair ); final LineNumberReader in = new LineNumberReader( new InputStreamReader( new FileInputStream(diffFile), Charset.defaultCharset() )); try { // Diff header // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - String line; line = in.readLine(); if( line == null || line.length() == 0 ) throw new BadDiffException( "no diff output", diffFile ); if( !line.startsWith( "---" )) { final Matcher m = DiffCache.NO_DIFF_PATTERN.matcher( line ); if( !m.matches() ) throw new BadDiffException( "diff says '" + line + "'", diffFile ); VSession.get().info( "no difference between draft revisions " + aCore + " and " + bCore + " (FIX to handle this more gracefully)" ); throw new RestartResponseException( new WP_Message() ); } { final PageParameters linkP = new PageParameters( pP ); final BookmarkablePageLinkX aLink; { add( new Label( "a-aMnemonic", pair.aUserMnemonic() )); aLink = new BookmarkablePageLinkX( "a-a", WP_D.class, linkP ); aLink.setBody( aUsername ); add( aLink ); } line = in.readLine(); if( !line.startsWith( "+++" )) throw new BadDiffException( "missing '+++' line in diff header", diffFile ); final BookmarkablePageLinkX bLink; { add( new Label( "b-aMnemonic", pair.bUserMnemonic() )); bLink = new BookmarkablePageLinkX( "b-a", WP_D.class, linkP ); bLink.setBody( bUsername ); add( bLink ); } final BookmarkablePageLinkX sLink; final BookmarkablePageLinkX tLink; // other one if( bToSelect ) { sLink = bLink; tLink = aLink; linkP.remove( "s" ); } else { sLink = aLink; tLink = bLink; linkP.set( "s", "" ); } appendStyleClass( sLink, "selected" ); sLink.setEnabled( false ); sLink.add( AttributeModifier.replace( "id", "sLink" )); // for AnchorLine tLink.add( AttributeModifier.replace( "id", "tLink" )); // for AlterLine } // Patch controls (top) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - form = new DiffForm(); add( form ); if( patchBar == null ) appendStyleClass( form, "patchNoBar" ); addPatchButton( form, "patchControlTop", patchButtonValue, patchButtonTitle, bunW ); // Hunks // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final RepeatingView tbodyRepeating = new RepeatingView( "tbody" ); form.add( tbodyRepeating ); line = in.readLine(); int h = 1; while( line != null ) line = addHunkTo( tbodyRepeating, line, in, diffFile, h++, cycle ); } finally{ in.close(); } appendStyleClass( form, "rel-" + rel ); // Patch controls (bottom) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - addPatchButton( form, "patchControlBottom", patchButtonValue, patchButtonTitle, bunW ); // Feedback messages // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - add( new WC_Feedback( "feedback" )); // = = = setCacheable( true ); setCacheDuration( CACHE_DURATION_YEAR ); } // - I - H e a d e r - C o n t r i b u t o r ------------------------------------------ public @Override void renderHead( IHeaderResponse r ) { super.renderHead( r ); if( !getSession().getFeedbackMessages().isEmpty() ) { r.renderOnLoadJavaScript( "location.hash = 'feedback'" ); } } //// P r i v a t e /////////////////////////////////////////////////////////////////////// /** @param line the header of the hunk. * @param h the hunk number. * * @return the header of the next hunk, or null if there is none. */ private String addHunkTo( final RepeatingView tbodyRepeating, String line, final LineNumberReader in, final File diffFile, final int h, final VRequestCycle cycle ) throws IOException { if( !line.startsWith( "@@" )) throw new BadDiffException( "bad hunk header '" + line + "'", diffFile ); final BundleFormatter bunW = cycle.bunW(); final Hunk hunk = new Hunk(); hunkList.add( hunk ); final String hunkID = "_" + h; final WebMarkupContainer tbody = new WebMarkupContainer( tbodyRepeating.newChildId() ); tbody.add( AttributeModifier.replace( "id", hunkID )); // here rather than in td // else when targeted, browser may scroll horizontally. FIX, it still scrolls a little. tbodyRepeating.add( tbody ); // Checkbox clicker // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final String checkID = "c" + h; final AttributeModifier checkTitler = AttributeModifier.replace( "title", bunW.l( "s.wic.diff.WP_D.checkTitle" )); { final CheckBox check = new CheckBox( "inputCheck", hunk ); tbody.add( check ); check.add( AttributeModifier.replace( "id", checkID )); if( patchBar == null ) { check.add( checkTitler ); check.add( new AttributeAppender( "onclick", new Model( "_a_diff_WP_D.onPatchCheckboxClick( " + h + " )" ), "; " )); } else check.setEnabled( false ); } { final WebMarkupContainer td = new WebMarkupContainer( "tdCheck" ); tbody.add( td ); if( patchBar == null ) { // td.add( checkTitler ); //// annoying td.add( new AttributeAppender( "onclick", new Model( "document.getElementById( '" + checkID + "' ).click()" ), "; " )); } } // Lines // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final RepeatingView trRepeating = new RepeatingView( "tr" ); tbody.add( trRepeating ); final Model aMnemonic = new Model( pair.aUserMnemonic() ); final Model bMnemonic = new Model( pair.bUserMnemonic() ); int segment = 0; // in-hunk difference, separated by one or more context lines boolean isSegmentIDSet = false; char prefixCharLast = 0; // force refresh String lineABClass = null; String lineUserClass = null; WebMarkupContainer trHunkTailContext = null; // top line of trailing context, if any for( ;; ) { line = in.readLine(); if( line == null ) { hunk.boundaryLine = in.getLineNumber() + 1; break; } if( line.startsWith( "@@" )) { hunk.boundaryLine = in.getLineNumber(); break; } final WebMarkupContainer tr = new WebMarkupContainer( trRepeating.newChildId() ); trRepeating.add( tr ); // th // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` final char prefixChar = line.charAt( 0 ); final Component th; if( prefixChar != prefixCharLast ) { if( prefixChar == ' ' ) // context line { final Fragment y = new Fragment( "th", "segmentLinkFrag", WP_D.this ); appendStyleClass( y, "segment" ); ++segment; // transit to context marks start of segment, or tail of hunk trHunkTailContext = tr; // till proven otherwise lineABClass = null; lineUserClass = null; final String segmentID = setSegmentID( hunkID, segment, tr ); isSegmentIDSet = true; y.add( new ExternalLink( "link", /*href*/"#" + segmentID, /*label*/segmentID.substring(1) )); th = y; } else // difference line { th = new Label( "th" ); appendStyleClass( th, "mnemonic" ); trHunkTailContext = null; // we cannot be in the tail, yet if( !isSegmentIDSet ) // hunk lacks leading context, ID not set above { ++segment; // all the same, we must be in a new segment setSegmentID( hunkID, segment, tr ); // set it here on a diff line isSegmentIDSet = true; } final Model mnemonic; final CoreRevision mnemonicCore; if( prefixChar == '-' ) { mnemonic = aMnemonic; mnemonicCore = pair.aCore(); lineABClass = "a"; } else { mnemonic = bMnemonic; mnemonicCore = pair.bCore(); lineABClass = "b"; } lineUserClass = userCoreOrNull == mnemonicCore? "u": "o"; th.setDefaultModel( mnemonic ); } prefixCharLast = prefixChar; } else th = new WebMarkupContainer( "th" ); // empty tr.add( th ); if( lineABClass != null ) appendStyleClass( tr, lineABClass ); if( lineUserClass != null ) appendStyleClass( tr, lineUserClass ); // td // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` String lineText = line.substring( 1 ); final boolean isEmpty = "".equals( lineText ); if( isEmpty ) lineText = " "; // ensure row is rendered, give it some guts final Label td = new Label( "td", lineText ); td.setEscapeModelStrings( !isEmpty ); tr.add( td ); } if( trHunkTailContext != null ) appendStyleClass( trHunkTailContext, "hunkTail" ); return line; } private void addPatchButton( final WebMarkupContainer y, final String id, final String patchButtonValue, final String patchButtonTitle, final BundleFormatter bunW ) { final Fragment yy = new Fragment( id, "patchControlFrag", WP_D.this ); y.add( yy ); final Button button = new Button( "button" ); button.add( AttributeModifier.replace( "value", patchButtonValue )); button.add( AttributeModifier.replace( "title", patchButtonTitle )); yy.add( button ); final Component loginLink; if( patchBar == null ) loginLink = newNullComponent( "loginLink" ); else { button.setEnabled( false ); if( patchBar.equals( PATCH_BAR_LOGGED_OUT )) { loginLink = new WC_LoginLink( "loginLink", WP_D.this, bunW.l( "s.wic.diff.WP_D.login" )); } else if( patchBar.equals( PATCH_BAR_NON_AUTHOR )) // typical case, make no noise { loginLink = newNullComponent( "loginLink" ); } else // rare edge case, just render it as a disabled link for now { loginLink = new ExternalLink( "loginLink", ".", patchBar ); loginLink.setEnabled( false ); } } yy.add( loginLink ); } private static void appendIfFile( final File fileOrNull, final Charset charset, final Writer out ) throws IOException { if( fileOrNull != null ) FileX.appendTo( out, fileOrNull, charset ); } private static void appendIfFile( final File fileOrNull, final Charset charset, final Writer out, final LineTransformer1 transformer ) throws IOException { if( fileOrNull == null ) return; final BufferedReader in = new BufferedReader( new InputStreamReader( new FileInputStream(fileOrNull), charset )); try { for( ;; ) { final String l = in.readLine(); if( l == null ) break; transformer.appendToWiki( l, out ); } } finally{ in.close(); } } private final File diffFile; private static final String FAILURE_PAGE_TITLE = "Unable to patch"; // failure messages not currently localized // /** The pattern of a hunk header. // */ // private static final Pattern HUNK_HEADER_PATTERN = Pattern.compile( // "^@@ -\\d+,\\d+ \\+\\d+,\\d+ @@$" ); // // @@ -11,6 +12,7 @@ private ArrayList hunkList = new ArrayList(); private static int legacyRev( final PageParameters pP, final String key ) { final int rev = legacyRevOptional( pP, key ); if( rev < 0 ) { VSession.get().error( "missing query parameter 'k'" ); throw new RestartResponseException( new WP_Message() ); } return rev; } private static int legacyRevOptional( final PageParameters pP, final String keyR ) { final String revString = stringNonEmpty( pP, keyR ); if( revString == null ) return -1; final int rev = Integer.parseInt( revString ); if( rev < 0 ) throw new IllegalArgumentException(); return rev; } private static final Logger logger = LoggerX.i( WP_D.class ); private DraftPair pair; /** If non-null, patching is disallowed. */ private String patchBar; private static final String PATCH_BAR_LOGGED_OUT = "Login required"; private static final String PATCH_BAR_NON_AUTHOR = "Patching others' drafts not allowed"; private static String setSegmentID( final String hunkID, final int segment, final WebMarkupContainer tr ) { final String segmentID = hunkID + "." + segment; tr.add( AttributeModifier.replace( "id", segmentID )); return segmentID; } /** Either pair.aCore or pair.bCore, whichever is the user's. */ private CoreRevision userCoreOrNull; // ==================================================================================== private static final class BadDiffException extends RuntimeException { BadDiffException( final String message, final File diffFile ) { super( message + ": " + diffFile ); } } // ==================================================================================== private final class DiffForm extends StatelessForm { private DiffForm() { super( "form" ); } protected @Override void onSubmit() { super.onSubmit(); if( patchBar != null ) throw new IllegalStateException(); // probably impossible final VRequestCycle cycle = VRequestCycle.get(); // Ensure at least one hunk is included // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - int hunkCount = 0; for( final Hunk hunk: hunkList ) if( hunk.getObject() ) ++hunkCount; if( hunkCount == 0 ) { VSession.get().getFeedbackMessages().warn( /*reporter*/DiffForm.this, cycle.bunW().l( "s.wic.diff.WP_D.patchFail.noHunk" )); return; } final DraftRevision userDraft = userCoreOrNull.draft(); final URI targetScriptLocation = userDraft.wikiScriptURI(); try { final Spool tmpFileSpool = new Spool1(); // Construct the patch file from the selected hunks // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final File patchFile = File.createTempFile( "WP_D_patch", "." + userDraft.pageID() ); tmpFileSpool.add( new FileHold( patchFile )); // Create patch as file rather than feeding it in via stdin. I want to // pull the output via stdout and don't want to mess with threads. For // an example of that alternative, see thread 'feeder': // http://reluk.ca/var/db/repo/votorola/file/fd139156408c/votorola/a/diff/WP_Diff.java final Charset nativeCharset = Charset.defaultCharset(); { final LineNumberReader in = new LineNumberReader( new InputStreamReader( new FileInputStream(diffFile), nativeCharset )); // reading from native charset, only for sake of line counting try { final BufferedWriter out = new BufferedWriter( new OutputStreamWriter( new FileOutputStream(patchFile), nativeCharset )); // back to native try { boolean toInclude = true; // i.e. include all lines (header) prior to first hunk for( int h = -1, boundaryLine = 3;; ) // the first hunk starts at line 3 { final String l = in.readLine(); if( l == null ) break; if( in.getLineNumber() == boundaryLine ) // on a new hunk { ++h; final Hunk hunk = hunkList.get( h ); toInclude = hunk.getObject(); boundaryLine = hunk.boundaryLine; } if( !toInclude ) continue; out.append( l ); out.newLine(); } } finally{ out.close(); } } finally{ in.close(); } } // Fetch the user's draft text as the target file // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final File[] targetFileSplit = DiffCache.LINE_TRANSFORMER.fetchPageAsFile( targetScriptLocation, "curid", userDraft.pageID(), "WP_D_target", tmpFileSpool ); final File targetFile = targetFileSplit[1]; // Apply the patch to the target file // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final boolean isReversePatch = userCoreOrNull.equals( pair.bCore() ); final CoreRevision otherCore = // as opposed to userCoreOrNull isReversePatch? pair.aCore(): pair.bCore(); final StringBuilder outB = new StringBuilder(); // not currently localized { final ProcessBuilder pB = new ProcessBuilder( "/bin/bash", "-c", "patch --force --no-backup-if-mismatch --unified " + (isReversePatch? "--reverse ": "") + targetFile.getName() + " " + patchFile.getName() + " 2>&1" ); pB.directory( patchFile.getParentFile() ); logger.fine( "calling out to OS: " + pB.command() ); pB.directory( patchFile.getParentFile() ); final Process p = pB.start(); outB.append( "PATCHING" ).append( '\n' ); outB.append( "--------" ).append( '\n' ); ProcessX.appendTo( outB, p, nativeCharset ); final int exitValue = ProcessX.waitForWithoutInterrupt( p ); if( exitValue == 1 ) // some hunks won't apply or merge hit conflicts { outB.append( '\n' ); outB.append( "patch attempt failed" ).append( '\n' ); cycle.setResponsePage( new WP_Message( FAILURE_PAGE_TITLE, outB.toString() ).pre()); return; } else if( exitValue != 0 ) // == 2 which means severe error { throw new IOException( "exit value of " + exitValue + " from process: " + pB.command() + " : " + outB.toString() ); } } // Login if necessary (currently always) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - final URI api; try{ api = new URI( userDraft.wikiScriptURI().toASCIIString() + "/api.php" ); } catch( URISyntaxException x ) { throw new RuntimeException( x ); } String editToken = null; token: if( editToken == null ) { outB.append( '\n' ); outB.append( "LOGGING IN" ).append( '\n' ); outB.append( "----------" ).append( '\n' ); { final String entry = "requesting login to " + api; logger.fine( entry ); outB.append( entry ).append( '\n' ); } final VOWicket app = VOWicket.get(); final CookieHandler cookieHandler = app.cookieManager(); final String errorMessage = MediaWiki.login( // FIX by detecting existing login in prior query or cookies api, cookieHandler, "Vobot", app.vsRun().voteServer().pollwiki().password() ); if( errorMessage != null ) { outB.append( '\n' ); outB.append( errorMessage ); outB.append( '\n' ); cycle.setResponsePage( new WP_Message( FAILURE_PAGE_TITLE, outB.toString() ).pre()); return; } // request edit token // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` final URI queryURI; try { queryURI = new URI( api.toString() + "?format=xml&action=query&prop=info&intoken=edit&pageids=" + userDraft.pageID() ); logger.fine( "requesting edit token for user's draft: " + queryURI ); } catch( URISyntaxException x ) { throw new RuntimeException( x ); } final URLConnection http = queryURI.toURL().openConnection(); URLConnectionX.setRequestCookies( queryURI, http, cookieHandler ); // after other req headers final Spool spool = new Spool1(); try { final XMLStreamReader xml = MediaWiki.requestXML( http, spool ); cookieHandler.put( queryURI, http.getHeaderFields() ); while( xml.hasNext() ) { xml.next(); if( !xml.isStartElement() ) continue; if( "page".equals( xml.getLocalName() )) { editToken = xml.getAttributeValue( /*ns*/null, "edittoken" ); break token; } MediaWiki.test_badrevids( xml ); MediaWiki.test_error( xml ); } } finally{ spool.unwind(); } throw new IllegalStateException(); } // Post patched file to wiki // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - outB.append( '\n' ); outB.append( "POSTING" ).append( '\n' ); outB.append( "-------" ).append( '\n' ); { // We cannot redirect the browser to submit the changes via the wiki // interactively, i.e. showing a preview or diff screen in advance of // saving the changes. Changes must be posted and clients cannot be // forced to post in response to a redirect. // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3 { final String entry = "posting changes to " + api; logger.fine( entry ); outB.append( entry ).append( '\n' ); } final HttpURLConnection http = (HttpURLConnection)api.toURL().openConnection(); http.setDoOutput( true ); // automatically does setRequestMethod( "POST" ) // http.setChunkedStreamingMode( /*chunk length, default*/0 ); /// fails, giving the API help page (1.16.1) http.setRequestProperty( "Content-Type", "application/x-www-form-urlencoded;charset=" + API_POST_CHARSET ); final CookieManager cookieManager = VOWicket.get().cookieManager(); URLConnectionX.setRequestCookies( api, http, cookieManager ); // after other req headers final Spool spool = new Spool1(); try { URLConnectionX.connect( http ); spool.add( new Hold() { public void release() { http.disconnect(); }} ); // write // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` { Writer out = new BufferedWriter( new OutputStreamWriter( http.getOutputStream(), API_POST_CHARSET )); try { out.append( "format=xml&action=edit&title=" ); out.append( URLEncoder.encode( userDraft.pageName(), API_POST_CHARSET )); // must be specified by title, pageids and revids both // give error "title parameter must be set" out.append( "&summary=" ); out.append( URLEncoder.encode( "Patch from " + otherCore.pageName(), API_POST_CHARSET )); out.append( "&token=" ); out.append( URLEncoder.encode( editToken, API_POST_CHARSET )); out.append( "&text=" ); // remainder of output is encoded: out = new URLEncodedWriter( API_POST_CHARSET, out ); appendIfFile( /*voHiBrac*/targetFileSplit[0], nativeCharset, out, DiffCache.LINE_TRANSFORMER ); appendIfFile( targetFile/*targetFileSplit[1]*/, nativeCharset, out, DiffCache.LINE_TRANSFORMER ); appendIfFile( /*voLoBrac*/targetFileSplit[2], nativeCharset, out ); } finally{ out.close(); } } // read // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` cookieManager.put( api, http.getHeaderFields() ); final InputStream in = http.getInputStream(); spool.add( new Hold() { public void release() { try{ in.close(); } catch( IOException x ) { throw new RuntimeException( x ); } } }); final XMLStreamReader xml = MediaWiki.newXMLStreamReader( in, spool ); while( xml.hasNext() ) { xml.next(); if( !xml.isStartElement() ) continue; if( "edit".equals( xml.getLocalName() )) { final String result = xml.getAttributeValue( /*ns*/null, "result" ); if( !result.equals( "Success" )) { outB.append( '\n' ); outB.append( "post attempt failed with result: " ); outB.append( result ).append ( '\n' ); cycle.setResponsePage( new WP_Message( FAILURE_PAGE_TITLE, outB.toString() ).pre()); return; } } MediaWiki.test_error( xml ); } } finally{ spool.unwind(); } } tmpFileSpool.unwind(); // if no exceptions, otherwise keep files for admin to diagnose } catch( IOException|XMLStreamException x ) { throw new RuntimeException( x ); } // Redirect to wiki history // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - try { final URI uri = new URI( targetScriptLocation + "/index.php?action=history&curid=" + userDraft.pageID() ); logger.finest( "redirecting client to " + uri ); throw new RedirectException( uri.toASCIIString(), 303 ); // user takes it from there } catch( URISyntaxException x ) { throw new RuntimeException( x ); } } } // ==================================================================================== /** A hunk implemented as an overloaded Wicket model. The members of the model proper * answer whether to include the hunk in any patch request, while the other members * record other properties of the hunk. */ private static final class Hunk extends Model { /** The base-one line number of the succeeding hunk header within the * diff output. For the final hunk, this is one plus the final line * number. */ int boundaryLine; // final after external init } } // NOTES // // [1] Convenience redirect 'voterDraft' is yanked as dead code. If it ever needs // salvaging, here it is: // // The name of a position page in the local wiki. The requester is redirected to // the difference between the latest revisions of that position's draft and the // corresponding candidate draft. // // // voterDraft // // ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` ` // final String voterDraft = stringNonEmpty( pP, "voterDraft" ); // if( voterDraft != null ) // { // pP.remove( "voterDraft" ); // final MatchResult m = Position.ensurePageName( voterDraft ); // final String pollName = m.group( 3 ); // final String voterName = m.group( 2 ); // final IDPair voter; // try{ voter = IDPair.fromUsername( voterName ); } // catch( AddressException x ) // { // throw new MediaWiki.IDException( "Not a positional page: '" // + voterName + "' is not a mailish username:" + x ); // } // // final Vote vote; // try // { // vote = new Vote( voter, // vsRun.scopePoll().ensurePoll(pollName).voterInputTable() ); // } // catch( ScriptException x ) { throw new RuntimeException( x ); } // // final IDPair candidate = vote.getCandidate(); // if( candidate.equals( IDPair.NOBODY )) // { // VSession.get().info( "no candidate, " + voterName + " has not voted in '" // + pollName + "'" ); // throw new RestartResponseException( new WP_Message() ); // } // // if( voter.equals( candidate )) // { // VSession.get().info( "unable to diff vs. candidate, " + voterName + // " is voting for self" ); // throw new RestartResponseException( new WP_Message() ); // } // // DraftPair pair = DraftPair.newDraftPair( voterDraft, // wiki.positionPageName(candidate.username(),pollName), vS ); // if( !pair.aCore().person().equals( voter )) pair = pair.newReversePair(); // revPut( pP, "a", pair.a() ); // revPut( pP, "aR", pair.aR() ); // revPut( pP, "b", pair.b() ); // revPut( pP, "bR", pair.bR() ); // throw new RedirectException( cycle.uriFor(WP_D.class,pP).toString(), 303 ); // }