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 = "\">&lt;&lt;</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 = "\">&gt;&gt;</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 = "&nbsp;";
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 &nbsp;
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}