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