| /* |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, |
| software distributed under the License is distributed on an |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| KIND, either express or implied. See the License for the |
| specific language governing permissions and limitations |
| under the License. |
| */ |
| |
| package org.apache.wiki.diff; |
| |
| import org.apache.log4j.Logger; |
| import org.apache.wiki.api.core.Context; |
| import org.apache.wiki.api.core.Engine; |
| import org.apache.wiki.api.exceptions.NoRequiredPropertyException; |
| import org.apache.wiki.util.TextUtil; |
| import org.suigeneris.jrcs.diff.Diff; |
| import org.suigeneris.jrcs.diff.DifferentiationFailedException; |
| import org.suigeneris.jrcs.diff.Revision; |
| import org.suigeneris.jrcs.diff.RevisionVisitor; |
| import org.suigeneris.jrcs.diff.delta.AddDelta; |
| import org.suigeneris.jrcs.diff.delta.ChangeDelta; |
| import org.suigeneris.jrcs.diff.delta.Chunk; |
| import org.suigeneris.jrcs.diff.delta.DeleteDelta; |
| import org.suigeneris.jrcs.diff.delta.Delta; |
| import org.suigeneris.jrcs.diff.myers.MyersDiff; |
| |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Properties; |
| import java.util.StringTokenizer; |
| |
| |
| /** |
| * A seriously better diff provider, which highlights changes word-by-word using CSS. |
| * |
| * Suggested by John Volkar. |
| */ |
| public class ContextualDiffProvider implements DiffProvider { |
| |
| private static final Logger log = Logger.getLogger( ContextualDiffProvider.class ); |
| |
| /** |
| * A jspwiki.properties value to define how many characters are shown around the change context. |
| * The current value is <tt>{@value}</tt>. |
| */ |
| public static final String PROP_UNCHANGED_CONTEXT_LIMIT = "jspwiki.contextualDiffProvider.unchangedContextLimit"; |
| |
| //TODO all of these publics can become jspwiki.properties entries... |
| //TODO span title= can be used to get hover info... |
| |
| public boolean m_emitChangeNextPreviousHyperlinks = true; |
| |
| //Don't use spans here the deletion and insertions are nested in this... |
| public static String CHANGE_START_HTML = ""; //This could be a image '>' for a start marker |
| public static String CHANGE_END_HTML = ""; //and an image for an end '<' marker |
| public static String DIFF_START = "<div class=\"diff-wikitext\">"; |
| public static String DIFF_END = "</div>"; |
| |
| // Unfortunately we need to do dumb HTML here for RSS feeds. |
| |
| public static String INSERTION_START_HTML = "<font color=\"#8000FF\"><span class=\"diff-insertion\">"; |
| public static String INSERTION_END_HTML = "</span></font>"; |
| public static String DELETION_START_HTML = "<strike><font color=\"red\"><span class=\"diff-deletion\">"; |
| public static String DELETION_END_HTML = "</span></font></strike>"; |
| private static final String ANCHOR_PRE_INDEX = "<a name=\"change-"; |
| private static final String ANCHOR_POST_INDEX = "\" />"; |
| private static final String BACK_PRE_INDEX = "<a class=\"diff-nextprev\" title=\"Go to previous change\" href=\"#change-"; |
| private static final String BACK_POST_INDEX = "\"><<</a>"; |
| private static final String FORWARD_PRE_INDEX = "<a class=\"diff-nextprev\" title=\"Go to next change\" href=\"#change-"; |
| private static final String FORWARD_POST_INDEX = "\">>></a>"; |
| public static String ELIDED_HEAD_INDICATOR_HTML = "<br/><br/><b>...</b>"; |
| public static String ELIDED_TAIL_INDICATOR_HTML = "<b>...</b><br/><br/>"; |
| public static String LINE_BREAK_HTML = "<br />"; |
| public static String ALTERNATING_SPACE_HTML = " "; |
| |
| // This one, I will make property file based... |
| private static final int LIMIT_MAX_VALUE = (Integer.MAX_VALUE /2) - 1; |
| private int m_unchangedContextLimit = LIMIT_MAX_VALUE; |
| |
| |
| /** |
| * Constructs this provider. |
| */ |
| public ContextualDiffProvider() |
| {} |
| |
| /** |
| * @see org.apache.wiki.api.providers.WikiProvider#getProviderInfo() |
| * |
| * {@inheritDoc} |
| */ |
| @Override |
| public String getProviderInfo() |
| { |
| return "ContextualDiffProvider"; |
| } |
| |
| /** |
| * @see org.apache.wiki.api.providers.WikiProvider#initialize(org.apache.wiki.api.core.Engine, java.util.Properties) |
| * |
| * {@inheritDoc} |
| */ |
| @Override |
| public void initialize( final Engine engine, final Properties properties) throws NoRequiredPropertyException, IOException { |
| final String configuredLimit = properties.getProperty( PROP_UNCHANGED_CONTEXT_LIMIT, Integer.toString( LIMIT_MAX_VALUE ) ); |
| int limit = LIMIT_MAX_VALUE; |
| try { |
| limit = Integer.parseInt( configuredLimit ); |
| } catch( final NumberFormatException e ) { |
| log.warn("Failed to parseInt " + PROP_UNCHANGED_CONTEXT_LIMIT + "=" + configuredLimit + " Will use a huge number as limit.", e ); |
| } |
| m_unchangedContextLimit = limit; |
| } |
| |
| |
| |
| /** |
| * Do a colored diff of the two regions. This. is. serious. fun. ;-) |
| * |
| * @see org.apache.wiki.diff.DiffProvider#makeDiffHtml(Context, String, String) |
| * |
| * {@inheritDoc} |
| */ |
| @Override |
| public synchronized String makeDiffHtml( final Context ctx, final String wikiOld, final String wikiNew ) { |
| // |
| // Sequencing handles lineterminator to <br /> and every-other consequtive space to a |
| // |
| final String[] alpha = sequence( TextUtil.replaceEntities( wikiOld ) ); |
| final String[] beta = sequence( TextUtil.replaceEntities( wikiNew ) ); |
| |
| final Revision rev; |
| try { |
| rev = Diff.diff( alpha, beta, new MyersDiff() ); |
| } catch( final DifferentiationFailedException dfe ) { |
| log.error( "Diff generation failed", dfe ); |
| return "Error while creating version diff."; |
| } |
| |
| final int revSize = rev.size(); |
| final StringBuffer sb = new StringBuffer(); |
| |
| sb.append( DIFF_START ); |
| |
| // |
| // The MyersDiff is a bit dumb by converting a single line multi-word diff into a series |
| // of Changes. The ChangeMerger pulls them together again... |
| // |
| final ChangeMerger cm = new ChangeMerger( sb, alpha, revSize ); |
| rev.accept( cm ); |
| cm.shutdown(); |
| sb.append( DIFF_END ); |
| return sb.toString(); |
| } |
| |
| /** |
| * Take the string and create an array from it, split it first on newlines, making |
| * sure to preserve the newlines in the elements, split each resulting element on |
| * spaces, preserving the spaces. |
| * |
| * All this preseving of newlines and spaces is so the wikitext when diffed will have fidelity |
| * to it's original form. As a side affect we see edits of purely whilespace. |
| */ |
| private String[] sequence( final String wikiText ) { |
| final String[] linesArray = Diff.stringToArray( wikiText ); |
| final List< String > list = new ArrayList<>(); |
| for( final String line : linesArray ) { |
| |
| String lastToken = null; |
| String token; |
| // StringTokenizer might be discouraged but it still is perfect here... |
| for( final StringTokenizer st = new StringTokenizer( line, " ", true ); st.hasMoreTokens(); ) { |
| token = st.nextToken(); |
| |
| if( " ".equals( lastToken ) && " ".equals( token ) ) { |
| token = ALTERNATING_SPACE_HTML; |
| } |
| |
| list.add( token ); |
| lastToken = token; |
| } |
| |
| list.add( LINE_BREAK_HTML ); // Line Break |
| } |
| |
| return list.toArray( new String[ 0 ] ); |
| } |
| |
| /** |
| * This helper class does the housekeeping for merging |
| * our various changes down and also makes sure that the |
| * whole change process is threadsafe by encapsulating |
| * all necessary variables. |
| */ |
| private final class ChangeMerger implements RevisionVisitor { |
| private final StringBuffer m_sb; |
| |
| /** Keeping score of the original lines to process */ |
| private final int m_max; |
| |
| private int m_index = 0; |
| |
| /** Index of the next element to be copied into the output. */ |
| private int m_firstElem = 0; |
| |
| /** Link Anchor counter */ |
| private int m_count = 1; |
| |
| /** State Machine Mode */ |
| private int m_mode = -1; /* -1: Unset, 0: Add, 1: Del, 2: Change mode */ |
| |
| /** Buffer to coalesce the changes together */ |
| private StringBuffer m_origBuf; |
| |
| private StringBuffer m_newBuf; |
| |
| /** Reference to the source string array */ |
| private final String[] m_origStrings; |
| |
| private ChangeMerger( final StringBuffer sb, final String[] origStrings, final int max ) { |
| m_sb = sb; |
| m_origStrings = origStrings != null ? origStrings.clone() : null; |
| m_max = max; |
| |
| m_origBuf = new StringBuffer(); |
| m_newBuf = new StringBuffer(); |
| } |
| |
| private void updateState( final Delta delta ) { |
| m_index++; |
| final Chunk orig = delta.getOriginal(); |
| if( orig.first() > m_firstElem ) { |
| // We "skip" some lines in the output. |
| // So flush out the last Change, if one exists. |
| flushChanges(); |
| |
| // Allow us to "skip" large swaths of unchanged text, show a "limited" amound of |
| // unchanged context so the changes are shown in |
| if( ( orig.first() - m_firstElem ) > 2 * m_unchangedContextLimit ) { |
| if (m_firstElem > 0) { |
| final int endIndex = Math.min( m_firstElem + m_unchangedContextLimit, m_origStrings.length -1 ); |
| |
| for( int j = m_firstElem; j < endIndex; j++ ) { |
| m_sb.append( m_origStrings[ j ] ); |
| } |
| |
| m_sb.append( ELIDED_TAIL_INDICATOR_HTML ); |
| } |
| |
| m_sb.append( ELIDED_HEAD_INDICATOR_HTML ); |
| |
| final int startIndex = Math.max(orig.first() - m_unchangedContextLimit, 0); |
| for (int j = startIndex; j < orig.first(); j++) { |
| m_sb.append( m_origStrings[ j ] ); |
| } |
| |
| } else { |
| // No need to skip anything, just output the whole range... |
| for( int j = m_firstElem; j < orig.first(); j++ ) { |
| m_sb.append( m_origStrings[ j ] ); |
| } |
| } |
| } |
| m_firstElem = orig.last() + 1; |
| } |
| |
| @Override |
| public void visit( final Revision rev ) { |
| // GNDN (Goes nowhere, does nothing) |
| } |
| |
| @Override |
| public void visit( final AddDelta delta ) { |
| updateState( delta ); |
| |
| // We have run Deletes up to now. Flush them out. |
| if( m_mode == 1 ) { |
| flushChanges(); |
| m_mode = -1; |
| } |
| // We are in "neutral mode". Start a new Change |
| if( m_mode == -1 ) { |
| m_mode = 0; |
| } |
| |
| // We are in "add mode". |
| if( m_mode == 0 || m_mode == 2 ) { |
| addNew( delta.getRevised() ); |
| m_mode = 1; |
| } |
| } |
| |
| @Override |
| public void visit( final ChangeDelta delta ) { |
| updateState( delta ); |
| |
| // We are in "neutral mode". A Change might be merged with an add or delete. |
| if( m_mode == -1 ) { |
| m_mode = 2; |
| } |
| |
| // Add the Changes to the buffers. |
| addOrig( delta.getOriginal() ); |
| addNew( delta.getRevised() ); |
| } |
| |
| @Override |
| public void visit( final DeleteDelta delta ) { |
| updateState( delta ); |
| |
| // We have run Adds up to now. Flush them out. |
| if( m_mode == 0 ) { |
| flushChanges(); |
| m_mode = -1; |
| } |
| // We are in "neutral mode". Start a new Change |
| if( m_mode == -1 ) { |
| m_mode = 1; |
| } |
| |
| // We are in "delete mode". |
| if( m_mode == 1 || m_mode == 2 ) { |
| addOrig( delta.getOriginal() ); |
| m_mode = 1; |
| } |
| } |
| |
| public void shutdown() { |
| m_index = m_max + 1; // Make sure that no hyperlink gets created |
| flushChanges(); |
| |
| if( m_firstElem < m_origStrings.length ) { |
| // If there's more than the limit of the orginal left just emit limit and elided... |
| if( ( m_origStrings.length - m_firstElem ) > m_unchangedContextLimit ) { |
| final int endIndex = Math.min( m_firstElem + m_unchangedContextLimit, m_origStrings.length -1 ); |
| for (int j = m_firstElem; j < endIndex; j++) { |
| m_sb.append( m_origStrings[ j ] ); |
| } |
| |
| m_sb.append( ELIDED_TAIL_INDICATOR_HTML ); |
| } else { |
| // emit entire tail of original... |
| for( int j = m_firstElem; j < m_origStrings.length; j++ ) { |
| m_sb.append( m_origStrings[ j ] ); |
| } |
| } |
| } |
| } |
| |
| private void addOrig( final Chunk chunk ) { |
| if( chunk != null ) { |
| chunk.toString( m_origBuf ); |
| } |
| } |
| |
| private void addNew( final Chunk chunk ) { |
| if( chunk != null ) { |
| chunk.toString( m_newBuf ); |
| } |
| } |
| |
| private void flushChanges() { |
| if( m_newBuf.length() + m_origBuf.length() > 0 ) { |
| // This is the span element which encapsulates anchor and the change itself |
| m_sb.append( CHANGE_START_HTML ); |
| |
| // Do we want to have a "back link"? |
| if( m_emitChangeNextPreviousHyperlinks && m_count > 1 ) { |
| m_sb.append( BACK_PRE_INDEX ); |
| m_sb.append( m_count - 1 ); |
| m_sb.append( BACK_POST_INDEX ); |
| } |
| |
| // An anchor for the change. |
| if (m_emitChangeNextPreviousHyperlinks) { |
| m_sb.append( ANCHOR_PRE_INDEX ); |
| m_sb.append( m_count++ ); |
| m_sb.append( ANCHOR_POST_INDEX ); |
| } |
| |
| // ... has been added |
| if( m_newBuf.length() > 0 ) { |
| m_sb.append( INSERTION_START_HTML ); |
| m_sb.append( m_newBuf ); |
| m_sb.append( INSERTION_END_HTML ); |
| } |
| |
| // .. has been removed |
| if( m_origBuf.length() > 0 ) { |
| m_sb.append( DELETION_START_HTML ); |
| m_sb.append( m_origBuf ); |
| m_sb.append( DELETION_END_HTML ); |
| } |
| |
| // Do we want a "forward" link? |
| if( m_emitChangeNextPreviousHyperlinks && (m_index < m_max) ) { |
| m_sb.append( FORWARD_PRE_INDEX ); |
| m_sb.append( m_count ); // Has already been incremented. |
| m_sb.append( FORWARD_POST_INDEX ); |
| } |
| |
| m_sb.append( CHANGE_END_HTML ); |
| |
| // Nuke the buffers. |
| m_origBuf = new StringBuffer(); |
| m_newBuf = new StringBuffer(); |
| } |
| |
| // After a flush, everything is reset. |
| m_mode = -1; |
| } |
| } |
| |
| } |