001/* 002 Licensed to the Apache Software Foundation (ASF) under one 003 or more contributor license agreements. See the NOTICE file 004 distributed with this work for additional information 005 regarding copyright ownership. The ASF licenses this file 006 to you under the Apache License, Version 2.0 (the 007 "License"); you may not use this file except in compliance 008 with the License. You may obtain a copy of the License at 009 010 http://www.apache.org/licenses/LICENSE-2.0 011 012 Unless required by applicable law or agreed to in writing, 013 software distributed under the License is distributed on an 014 "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 KIND, either express or implied. See the License for the 016 specific language governing permissions and limitations 017 under the License. 018*/ 019 020package org.apache.wiki.diff; 021 022import org.apache.log4j.Logger; 023import org.apache.wiki.api.core.Context; 024import org.apache.wiki.api.core.Engine; 025import org.apache.wiki.api.exceptions.NoRequiredPropertyException; 026import org.apache.wiki.util.TextUtil; 027import org.suigeneris.jrcs.diff.Diff; 028import org.suigeneris.jrcs.diff.DifferentiationFailedException; 029import org.suigeneris.jrcs.diff.Revision; 030import org.suigeneris.jrcs.diff.RevisionVisitor; 031import org.suigeneris.jrcs.diff.delta.AddDelta; 032import org.suigeneris.jrcs.diff.delta.ChangeDelta; 033import org.suigeneris.jrcs.diff.delta.Chunk; 034import org.suigeneris.jrcs.diff.delta.DeleteDelta; 035import org.suigeneris.jrcs.diff.delta.Delta; 036import org.suigeneris.jrcs.diff.myers.MyersDiff; 037 038import java.io.IOException; 039import java.util.ArrayList; 040import java.util.List; 041import java.util.Properties; 042import java.util.StringTokenizer; 043 044 045/** 046 * A seriously better diff provider, which highlights changes word-by-word using CSS. 047 * 048 * Suggested by John Volkar. 049 */ 050public class ContextualDiffProvider implements DiffProvider { 051 052 private static final Logger log = Logger.getLogger( ContextualDiffProvider.class ); 053 054 /** 055 * A jspwiki.properties value to define how many characters are shown around the change context. 056 * The current value is <tt>{@value}</tt>. 057 */ 058 public static final String PROP_UNCHANGED_CONTEXT_LIMIT = "jspwiki.contextualDiffProvider.unchangedContextLimit"; 059 060 //TODO all of these publics can become jspwiki.properties entries... 061 //TODO span title= can be used to get hover info... 062 063 public boolean m_emitChangeNextPreviousHyperlinks = true; 064 065 //Don't use spans here the deletion and insertions are nested in this... 066 public static String CHANGE_START_HTML = ""; //This could be a image '>' for a start marker 067 public static String CHANGE_END_HTML = ""; //and an image for an end '<' marker 068 public static String DIFF_START = "<div class=\"diff-wikitext\">"; 069 public static String DIFF_END = "</div>"; 070 071 // Unfortunately we need to do dumb HTML here for RSS feeds. 072 073 public static String INSERTION_START_HTML = "<font color=\"#8000FF\"><span class=\"diff-insertion\">"; 074 public static String INSERTION_END_HTML = "</span></font>"; 075 public static String DELETION_START_HTML = "<strike><font color=\"red\"><span class=\"diff-deletion\">"; 076 public static String DELETION_END_HTML = "</span></font></strike>"; 077 private static final String ANCHOR_PRE_INDEX = "<a name=\"change-"; 078 private static final String ANCHOR_POST_INDEX = "\" />"; 079 private static final String BACK_PRE_INDEX = "<a class=\"diff-nextprev\" title=\"Go to previous change\" href=\"#change-"; 080 private static final String BACK_POST_INDEX = "\"><<</a>"; 081 private static final String FORWARD_PRE_INDEX = "<a class=\"diff-nextprev\" title=\"Go to next change\" href=\"#change-"; 082 private static final String FORWARD_POST_INDEX = "\">>></a>"; 083 public static String ELIDED_HEAD_INDICATOR_HTML = "<br/><br/><b>...</b>"; 084 public static String ELIDED_TAIL_INDICATOR_HTML = "<b>...</b><br/><br/>"; 085 public static String LINE_BREAK_HTML = "<br />"; 086 public static String ALTERNATING_SPACE_HTML = " "; 087 088 // This one, I will make property file based... 089 private static final int LIMIT_MAX_VALUE = (Integer.MAX_VALUE /2) - 1; 090 private int m_unchangedContextLimit = LIMIT_MAX_VALUE; 091 092 093 /** 094 * Constructs this provider. 095 */ 096 public ContextualDiffProvider() 097 {} 098 099 /** 100 * @see org.apache.wiki.api.providers.WikiProvider#getProviderInfo() 101 * 102 * {@inheritDoc} 103 */ 104 @Override 105 public String getProviderInfo() 106 { 107 return "ContextualDiffProvider"; 108 } 109 110 /** 111 * @see org.apache.wiki.api.providers.WikiProvider#initialize(org.apache.wiki.api.core.Engine, java.util.Properties) 112 * 113 * {@inheritDoc} 114 */ 115 @Override 116 public void initialize( final Engine engine, final Properties properties) throws NoRequiredPropertyException, IOException { 117 final String configuredLimit = properties.getProperty( PROP_UNCHANGED_CONTEXT_LIMIT, Integer.toString( LIMIT_MAX_VALUE ) ); 118 int limit = LIMIT_MAX_VALUE; 119 try { 120 limit = Integer.parseInt( configuredLimit ); 121 } catch( final NumberFormatException e ) { 122 log.warn("Failed to parseInt " + PROP_UNCHANGED_CONTEXT_LIMIT + "=" + configuredLimit + " Will use a huge number as limit.", e ); 123 } 124 m_unchangedContextLimit = limit; 125 } 126 127 128 129 /** 130 * Do a colored diff of the two regions. This. is. serious. fun. ;-) 131 * 132 * @see org.apache.wiki.diff.DiffProvider#makeDiffHtml(Context, String, String) 133 * 134 * {@inheritDoc} 135 */ 136 @Override 137 public synchronized String makeDiffHtml( final Context ctx, final String wikiOld, final String wikiNew ) { 138 // 139 // Sequencing handles lineterminator to <br /> and every-other consequtive space to a 140 // 141 final String[] alpha = sequence( TextUtil.replaceEntities( wikiOld ) ); 142 final String[] beta = sequence( TextUtil.replaceEntities( wikiNew ) ); 143 144 final Revision rev; 145 try { 146 rev = Diff.diff( alpha, beta, new MyersDiff() ); 147 } catch( final DifferentiationFailedException dfe ) { 148 log.error( "Diff generation failed", dfe ); 149 return "Error while creating version diff."; 150 } 151 152 final int revSize = rev.size(); 153 final StringBuffer sb = new StringBuffer(); 154 155 sb.append( DIFF_START ); 156 157 // 158 // The MyersDiff is a bit dumb by converting a single line multi-word diff into a series 159 // of Changes. The ChangeMerger pulls them together again... 160 // 161 final ChangeMerger cm = new ChangeMerger( sb, alpha, revSize ); 162 rev.accept( cm ); 163 cm.shutdown(); 164 sb.append( DIFF_END ); 165 return sb.toString(); 166 } 167 168 /** 169 * Take the string and create an array from it, split it first on newlines, making 170 * sure to preserve the newlines in the elements, split each resulting element on 171 * spaces, preserving the spaces. 172 * 173 * All this preseving of newlines and spaces is so the wikitext when diffed will have fidelity 174 * to it's original form. As a side affect we see edits of purely whilespace. 175 */ 176 private String[] sequence( final String wikiText ) { 177 final String[] linesArray = Diff.stringToArray( wikiText ); 178 final List< String > list = new ArrayList<>(); 179 for( final String line : linesArray ) { 180 181 String lastToken = null; 182 String token; 183 // StringTokenizer might be discouraged but it still is perfect here... 184 for( final StringTokenizer st = new StringTokenizer( line, " ", true ); st.hasMoreTokens(); ) { 185 token = st.nextToken(); 186 187 if( " ".equals( lastToken ) && " ".equals( token ) ) { 188 token = ALTERNATING_SPACE_HTML; 189 } 190 191 list.add( token ); 192 lastToken = token; 193 } 194 195 list.add( LINE_BREAK_HTML ); // Line Break 196 } 197 198 return list.toArray( new String[ 0 ] ); 199 } 200 201 /** 202 * This helper class does the housekeeping for merging 203 * our various changes down and also makes sure that the 204 * whole change process is threadsafe by encapsulating 205 * all necessary variables. 206 */ 207 private final class ChangeMerger implements RevisionVisitor { 208 private StringBuffer m_sb; 209 210 /** Keeping score of the original lines to process */ 211 private int m_max; 212 213 private int m_index = 0; 214 215 /** Index of the next element to be copied into the output. */ 216 private int m_firstElem = 0; 217 218 /** Link Anchor counter */ 219 private int m_count = 1; 220 221 /** State Machine Mode */ 222 private int m_mode = -1; /* -1: Unset, 0: Add, 1: Del, 2: Change mode */ 223 224 /** Buffer to coalesce the changes together */ 225 private StringBuffer m_origBuf; 226 227 private StringBuffer m_newBuf; 228 229 /** Reference to the source string array */ 230 private String[] m_origStrings; 231 232 private ChangeMerger( final StringBuffer sb, final String[] origStrings, final int max ) { 233 m_sb = sb; 234 m_origStrings = origStrings != null ? origStrings.clone() : null; 235 m_max = max; 236 237 m_origBuf = new StringBuffer(); 238 m_newBuf = new StringBuffer(); 239 } 240 241 private void updateState( final Delta delta ) { 242 m_index++; 243 final Chunk orig = delta.getOriginal(); 244 if( orig.first() > m_firstElem ) { 245 // We "skip" some lines in the output. 246 // So flush out the last Change, if one exists. 247 flushChanges(); 248 249 // Allow us to "skip" large swaths of unchanged text, show a "limited" amound of 250 // unchanged context so the changes are shown in 251 if( ( orig.first() - m_firstElem ) > 2 * m_unchangedContextLimit ) { 252 if (m_firstElem > 0) { 253 final int endIndex = Math.min( m_firstElem + m_unchangedContextLimit, m_origStrings.length -1 ); 254 255 for( int j = m_firstElem; j < endIndex; j++ ) { 256 m_sb.append( m_origStrings[ j ] ); 257 } 258 259 m_sb.append( ELIDED_TAIL_INDICATOR_HTML ); 260 } 261 262 m_sb.append( ELIDED_HEAD_INDICATOR_HTML ); 263 264 final int startIndex = Math.max(orig.first() - m_unchangedContextLimit, 0); 265 for (int j = startIndex; j < orig.first(); j++) { 266 m_sb.append( m_origStrings[ j ] ); 267 } 268 269 } else { 270 // No need to skip anything, just output the whole range... 271 for( int j = m_firstElem; j < orig.first(); j++ ) { 272 m_sb.append( m_origStrings[ j ] ); 273 } 274 } 275 } 276 m_firstElem = orig.last() + 1; 277 } 278 279 @Override 280 public void visit( final Revision rev ) { 281 // GNDN (Goes nowhere, does nothing) 282 } 283 284 @Override 285 public void visit( final AddDelta delta ) { 286 updateState( delta ); 287 288 // We have run Deletes up to now. Flush them out. 289 if( m_mode == 1 ) { 290 flushChanges(); 291 m_mode = -1; 292 } 293 // We are in "neutral mode". Start a new Change 294 if( m_mode == -1 ) { 295 m_mode = 0; 296 } 297 298 // We are in "add mode". 299 if( m_mode == 0 || m_mode == 2 ) { 300 addNew( delta.getRevised() ); 301 m_mode = 1; 302 } 303 } 304 305 @Override 306 public void visit( final ChangeDelta delta ) { 307 updateState( delta ); 308 309 // We are in "neutral mode". A Change might be merged with an add or delete. 310 if( m_mode == -1 ) { 311 m_mode = 2; 312 } 313 314 // Add the Changes to the buffers. 315 addOrig( delta.getOriginal() ); 316 addNew( delta.getRevised() ); 317 } 318 319 @Override 320 public void visit( final DeleteDelta delta ) { 321 updateState( delta ); 322 323 // We have run Adds up to now. Flush them out. 324 if( m_mode == 0 ) { 325 flushChanges(); 326 m_mode = -1; 327 } 328 // We are in "neutral mode". Start a new Change 329 if( m_mode == -1 ) { 330 m_mode = 1; 331 } 332 333 // We are in "delete mode". 334 if( m_mode == 1 || m_mode == 2 ) { 335 addOrig( delta.getOriginal() ); 336 m_mode = 1; 337 } 338 } 339 340 public void shutdown() { 341 m_index = m_max + 1; // Make sure that no hyperlink gets created 342 flushChanges(); 343 344 if( m_firstElem < m_origStrings.length ) { 345 // If there's more than the limit of the orginal left just emit limit and elided... 346 if( ( m_origStrings.length - m_firstElem ) > m_unchangedContextLimit ) { 347 final int endIndex = Math.min( m_firstElem + m_unchangedContextLimit, m_origStrings.length -1 ); 348 for (int j = m_firstElem; j < endIndex; j++) { 349 m_sb.append( m_origStrings[ j ] ); 350 } 351 352 m_sb.append( ELIDED_TAIL_INDICATOR_HTML ); 353 } else { 354 // emit entire tail of original... 355 for( int j = m_firstElem; j < m_origStrings.length; j++ ) { 356 m_sb.append( m_origStrings[ j ] ); 357 } 358 } 359 } 360 } 361 362 private void addOrig( final Chunk chunk ) { 363 if( chunk != null ) { 364 chunk.toString( m_origBuf ); 365 } 366 } 367 368 private void addNew( final Chunk chunk ) { 369 if( chunk != null ) { 370 chunk.toString( m_newBuf ); 371 } 372 } 373 374 private void flushChanges() { 375 if( m_newBuf.length() + m_origBuf.length() > 0 ) { 376 // This is the span element which encapsulates anchor and the change itself 377 m_sb.append( CHANGE_START_HTML ); 378 379 // Do we want to have a "back link"? 380 if( m_emitChangeNextPreviousHyperlinks && m_count > 1 ) { 381 m_sb.append( BACK_PRE_INDEX ); 382 m_sb.append( m_count - 1 ); 383 m_sb.append( BACK_POST_INDEX ); 384 } 385 386 // An anchor for the change. 387 if (m_emitChangeNextPreviousHyperlinks) { 388 m_sb.append( ANCHOR_PRE_INDEX ); 389 m_sb.append( m_count++ ); 390 m_sb.append( ANCHOR_POST_INDEX ); 391 } 392 393 // ... has been added 394 if( m_newBuf.length() > 0 ) { 395 m_sb.append( INSERTION_START_HTML ); 396 m_sb.append( m_newBuf ); 397 m_sb.append( INSERTION_END_HTML ); 398 } 399 400 // .. has been removed 401 if( m_origBuf.length() > 0 ) { 402 m_sb.append( DELETION_START_HTML ); 403 m_sb.append( m_origBuf ); 404 m_sb.append( DELETION_END_HTML ); 405 } 406 407 // Do we want a "forward" link? 408 if( m_emitChangeNextPreviousHyperlinks && (m_index < m_max) ) { 409 m_sb.append( FORWARD_PRE_INDEX ); 410 m_sb.append( m_count ); // Has already been incremented. 411 m_sb.append( FORWARD_POST_INDEX ); 412 } 413 414 m_sb.append( CHANGE_END_HTML ); 415 416 // Nuke the buffers. 417 m_origBuf = new StringBuffer(); 418 m_newBuf = new StringBuffer(); 419 } 420 421 // After a flush, everything is reset. 422 m_mode = -1; 423 } 424 } 425 426}