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
020 package org.apache.wiki.diff;
021
022 import java.io.IOException;
023 import java.util.ArrayList;
024 import java.util.List;
025 import java.util.Properties;
026 import java.util.StringTokenizer;
027
028 import org.apache.log4j.Logger;
029 import org.apache.wiki.WikiContext;
030 import org.apache.wiki.WikiEngine;
031 import org.apache.wiki.api.exceptions.NoRequiredPropertyException;
032 import org.apache.wiki.util.TextUtil;
033 import org.suigeneris.jrcs.diff.Diff;
034 import org.suigeneris.jrcs.diff.DifferentiationFailedException;
035 import org.suigeneris.jrcs.diff.Revision;
036 import org.suigeneris.jrcs.diff.RevisionVisitor;
037 import org.suigeneris.jrcs.diff.delta.AddDelta;
038 import org.suigeneris.jrcs.diff.delta.ChangeDelta;
039 import org.suigeneris.jrcs.diff.delta.Chunk;
040 import org.suigeneris.jrcs.diff.delta.DeleteDelta;
041 import org.suigeneris.jrcs.diff.delta.Delta;
042 import 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 */
052 public 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 }