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     = "\">&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    }