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     = "\">&lt;&lt;</a>";
083    private String m_forwardPreIndex   = "<a class=\"diff-nextprev\" title=\"Go to next change\" href=\"#change-";
084    private String m_forwardPostIndex  = "\">&gt;&gt;</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 = "&nbsp;";
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 &nbsp;
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}