001: /*
002: JSPWiki - a JSP-based WikiWiki clone.
003:
004: Copyright (C) 2001-2005 Janne Jalkanen (Janne.Jalkanen@iki.fi)
005:
006: This program is free software; you can redistribute it and/or modify
007: it under the terms of the GNU Lesser General Public License as published by
008: the Free Software Foundation; either version 2.1 of the License, or
009: (at your option) any later version.
010:
011: This program is distributed in the hope that it will be useful,
012: but WITHOUT ANY WARRANTY; without even the implied warranty of
013: MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
014: GNU Lesser General Public License for more details.
015:
016: You should have received a copy of the GNU Lesser General Public License
017: along with this program; if not, write to the Free Software
018: Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
019: */
020:
021: package com.ecyrd.jspwiki.diff;
022:
023: import java.io.IOException;
024: import java.util.ArrayList;
025: import java.util.List;
026: import java.util.Properties;
027: import java.util.StringTokenizer;
028:
029: import org.apache.commons.jrcs.diff.*;
030: import org.apache.commons.jrcs.diff.myers.MyersDiff;
031: import org.apache.log4j.Logger;
032:
033: import com.ecyrd.jspwiki.NoRequiredPropertyException;
034: import com.ecyrd.jspwiki.TextUtil;
035: import com.ecyrd.jspwiki.WikiContext;
036: import com.ecyrd.jspwiki.WikiEngine;
037:
038: /**
039: * A seriously better diff provider, which highlights changes word-by-word using
040: * CSS.
041: *
042: * Suggested by John Volkar.
043: *
044: * @author John Volkar
045: * @author Janne Jalkanen
046: * @author <a href="mailto:hps@intermeta.de">Henning P. Schmiedehausen</a>
047: */
048:
049: public class ContextualDiffProvider implements DiffProvider {
050:
051: private static final Logger log = Logger
052: .getLogger(ContextualDiffProvider.class);
053:
054: public static final String PROP_UNCHANGED_CONTEXT_LIMIT = "jspwiki.contextualDiffProvider.unchangedContextLimit";
055:
056: //TODO all of these publics can become jspwiki.properties entries...
057: //TODO span title= can be used to get hover info...
058:
059: public boolean m_emitChangeNextPreviousHyperlinks = true;
060:
061: //Don't use spans here the deletion and insertions are nested in this...
062: public String m_changeStartHtml = ""; //This could be a image '>' for a start marker
063: public String m_changeEndHtml = ""; //and an image for an end '<' marker
064: public String m_diffStart = "<div class=\"diff-wikitext\">";
065: public String m_diffEnd = "</div>";
066:
067: // Unfortunately we need to do dumb HTML here for RSS feeds.
068:
069: public String m_insertionStartHtml = "<font color=\"#8000FF\"><span class=\"diff-insertion\">";
070: public String m_insertionEndHtml = "</span></font>";
071: public String m_deletionStartHtml = "<strike><font color=\"red\"><span class=\"diff-deletion\">";
072: public String m_deletionEndHtml = "</span></font></strike>";
073: private String m_anchorPreIndex = "<a name=\"change-";
074: private String m_anchorPostIndex = "\" />";
075: private String m_backPreIndex = "<a class=\"diff-nextprev\" title=\"Go to previous change\" href=\"#change-";
076: private String m_backPostIndex = "\"><<</a>";
077: private String m_forwardPreIndex = "<a class=\"diff-nextprev\" title=\"Go to next change\" href=\"#change-";
078: private String m_forwardPostIndex = "\">>></a>";
079: public String m_elidedHeadIndicatorHtml = "<br/><br/><b>...</b>";
080: public String m_elidedTailIndicatorHtml = "<b>...</b><br/><br/>";
081: public String m_lineBreakHtml = "<br />";
082: public String m_alternatingSpaceHtml = " ";
083:
084: // This one, I will make property file based...
085: private static final int LIMIT_MAX_VALUE = (Integer.MAX_VALUE / 2) - 1;
086: private int m_unchangedContextLimit = LIMIT_MAX_VALUE;
087:
088: public ContextualDiffProvider() {
089: }
090:
091: /**
092: * @see com.ecyrd.jspwiki.WikiProvider#getProviderInfo()
093: */
094: public String getProviderInfo() {
095: return "ContextualDiffProvider";
096: }
097:
098: /**
099: * @see com.ecyrd.jspwiki.WikiProvider#initialize(com.ecyrd.jspwiki.WikiEngine,
100: * java.util.Properties)
101: */
102: public void initialize(WikiEngine engine, Properties properties)
103: throws NoRequiredPropertyException, IOException {
104: String configuredLimit = properties.getProperty(
105: PROP_UNCHANGED_CONTEXT_LIMIT, Integer
106: .toString(LIMIT_MAX_VALUE));
107: int limit = LIMIT_MAX_VALUE;
108: try {
109: limit = Integer.parseInt(configuredLimit);
110: } catch (NumberFormatException e) {
111: log.warn("Failed to parseInt "
112: + PROP_UNCHANGED_CONTEXT_LIMIT + "="
113: + configuredLimit
114: + " Will use a huge number as limit.", e);
115: }
116: m_unchangedContextLimit = limit;
117: }
118:
119: /**
120: * Do a colored diff of the two regions. This. is. serious. fun. ;-)
121: *
122: * @see com.ecyrd.jspwiki.diff.DiffProvider#makeDiffHtml(java.lang.String,
123: * java.lang.String)
124: */
125: public synchronized String makeDiffHtml(WikiContext ctx,
126: String wikiOld, String wikiNew) {
127: //
128: // Sequencing handles lineterminator to <br /> and every-other consequtive space to a
129: //
130: String[] alpha = sequence(TextUtil.replaceEntities(wikiOld));
131: String[] beta = sequence(TextUtil.replaceEntities(wikiNew));
132:
133: Revision rev = null;
134: try {
135: rev = Diff.diff(alpha, beta, new MyersDiff());
136: } catch (DifferentiationFailedException dfe) {
137: log.error("Diff generation failed", dfe);
138: return "Error while creating version diff.";
139: }
140:
141: int revSize = rev.size();
142:
143: StringBuffer sb = new StringBuffer();
144:
145: sb.append(m_diffStart);
146:
147: //
148: // The MyersDiff is a bit dumb by converting a single line multi-word diff into a series
149: // of Changes. The ChangeMerger pulls them together again...
150: //
151: ChangeMerger cm = new ChangeMerger(sb, alpha, revSize);
152:
153: rev.accept(cm);
154:
155: cm.shutdown();
156:
157: sb.append(m_diffEnd);
158:
159: return sb.toString();
160: }
161:
162: /**
163: * Take the string and create an array from it, split it first on newlines, making
164: * sure to preserve the newlines in the elements, split each resulting element on
165: * spaces, preserving the spaces.
166: *
167: * All this preseving of newlines and spaces is so the wikitext when diffed will have fidelity
168: * to it's original form. As a side affect we see edits of purely whilespace.
169: */
170: private String[] sequence(String wikiText) {
171: String[] linesArray = Diff.stringToArray(wikiText);
172:
173: List list = new ArrayList();
174:
175: for (int i = 0; i < linesArray.length; i++) {
176: String line = linesArray[i];
177:
178: String lastToken = null;
179: String token = null;
180: // StringTokenizer might be discouraged but it still is perfect here...
181: for (StringTokenizer st = new StringTokenizer(line, " ",
182: true); st.hasMoreTokens();) {
183: token = st.nextToken();
184:
185: if (" ".equals(lastToken) && " ".equals(token))
186: token = m_alternatingSpaceHtml;
187:
188: list.add(token);
189: lastToken = token;
190: }
191:
192: list.add(m_lineBreakHtml); // Line Break
193: }
194:
195: return (String[]) list.toArray(new String[0]);
196: }
197:
198: /**
199: * This helper class does the housekeeping for merging
200: * our various changes down and also makes sure that the
201: * whole change process is threadsafe by encapsulating
202: * all necessary variables.
203: */
204: private final class ChangeMerger implements RevisionVisitor {
205: private StringBuffer m_sb = null;
206:
207: /** Keeping score of the original lines to process */
208: private int m_max = -1;
209:
210: private int m_index = 0;
211:
212: /** Index of the next element to be copied into the output. */
213: private int m_firstElem = 0;
214:
215: /** Link Anchor counter */
216: private int m_count = 1;
217:
218: /** State Machine Mode */
219: private int m_mode = -1; /* -1: Unset, 0: Add, 1: Del, 2: Change mode */
220:
221: /** Buffer to coalesce the changes together */
222: private StringBuffer m_origBuf = null;
223:
224: private StringBuffer m_newBuf = null;
225:
226: /** Reference to the source string array */
227: private String[] m_origStrings = null;
228:
229: private ChangeMerger(final StringBuffer sb,
230: final String[] origStrings, final int max) {
231: m_sb = sb;
232: m_origStrings = origStrings;
233: m_max = max;
234:
235: m_origBuf = new StringBuffer();
236: m_newBuf = new StringBuffer();
237: }
238:
239: private void updateState(Delta delta) {
240: m_index++;
241:
242: Chunk orig = delta.getOriginal();
243:
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: int endIndex = Math.min(m_firstElem
254: + m_unchangedContextLimit,
255: m_origStrings.length - 1);
256:
257: for (int j = m_firstElem; j < endIndex; j++)
258: m_sb.append(m_origStrings[j]);
259:
260: m_sb.append(m_elidedTailIndicatorHtml);
261: }
262:
263: m_sb.append(m_elidedHeadIndicatorHtml);
264:
265: int startIndex = Math.max(orig.first()
266: - m_unchangedContextLimit, 0);
267: for (int j = startIndex; j < orig.first(); j++)
268: m_sb.append(m_origStrings[j]);
269:
270: } else {
271: // No need to skip anything, just output the whole range...
272: for (int j = m_firstElem; j < orig.first(); j++)
273: m_sb.append(m_origStrings[j]);
274: }
275: }
276: m_firstElem = orig.last() + 1;
277: }
278:
279: public void visit(Revision rev) {
280: // GNDN (Goes nowhere, does nothing)
281: }
282:
283: public void visit(AddDelta delta) {
284: updateState(delta);
285:
286: // We have run Deletes up to now. Flush them out.
287: if (m_mode == 1) {
288: flushChanges();
289: m_mode = -1;
290: }
291: // We are in "neutral mode". Start a new Change
292: if (m_mode == -1) {
293: m_mode = 0;
294: }
295:
296: // We are in "add mode".
297: if (m_mode == 0 || m_mode == 2) {
298: addNew(delta.getRevised());
299: m_mode = 1;
300: }
301: }
302:
303: public void visit(ChangeDelta delta) {
304: updateState(delta);
305:
306: // We are in "neutral mode". A Change might be merged with an add or delete.
307: if (m_mode == -1) {
308: m_mode = 2;
309: }
310:
311: // Add the Changes to the buffers.
312: addOrig(delta.getOriginal());
313: addNew(delta.getRevised());
314: }
315:
316: public void visit(DeleteDelta delta) {
317: updateState(delta);
318:
319: // We have run Adds up to now. Flush them out.
320: if (m_mode == 0) {
321: flushChanges();
322: m_mode = -1;
323: }
324: // We are in "neutral mode". Start a new Change
325: if (m_mode == -1) {
326: m_mode = 1;
327: }
328:
329: // We are in "delete mode".
330: if (m_mode == 1 || m_mode == 2) {
331: addOrig(delta.getOriginal());
332: m_mode = 1;
333: }
334: }
335:
336: public void shutdown() {
337: m_index = m_max + 1; // Make sure that no hyperlink gets created
338: flushChanges();
339:
340: if (m_firstElem < m_origStrings.length) {
341: // If there's more than the limit of the orginal left just emit limit and elided...
342: if ((m_origStrings.length - m_firstElem) > m_unchangedContextLimit) {
343: int endIndex = Math.min(m_firstElem
344: + m_unchangedContextLimit,
345: m_origStrings.length - 1);
346:
347: for (int j = m_firstElem; j < endIndex; j++)
348: m_sb.append(m_origStrings[j]);
349:
350: m_sb.append(m_elidedTailIndicatorHtml);
351: } else
352: // emit entire tail of original...
353: {
354: for (int j = m_firstElem; j < m_origStrings.length; j++)
355: m_sb.append(m_origStrings[j]);
356: }
357: }
358: }
359:
360: private void addOrig(Chunk chunk) {
361: if (chunk != null) {
362: chunk.toString(m_origBuf);
363: }
364: }
365:
366: private void addNew(Chunk chunk) {
367: if (chunk != null) {
368: chunk.toString(m_newBuf);
369: }
370: }
371:
372: private void flushChanges() {
373:
374: if (m_newBuf.length() + m_origBuf.length() > 0) {
375: // This is the span element which encapsulates anchor and the change itself
376: m_sb.append(m_changeStartHtml);
377:
378: // Do we want to have a "back link"?
379: if (m_emitChangeNextPreviousHyperlinks && m_count > 1) {
380: m_sb.append(m_backPreIndex);
381: m_sb.append(m_count - 1);
382: m_sb.append(m_backPostIndex);
383: }
384:
385: // An anchor for the change.
386: if (m_emitChangeNextPreviousHyperlinks) {
387: m_sb.append(m_anchorPreIndex);
388: m_sb.append(m_count++);
389: m_sb.append(m_anchorPostIndex);
390: }
391:
392: // ... has been added
393: if (m_newBuf.length() > 0) {
394: m_sb.append(m_insertionStartHtml);
395: m_sb.append(m_newBuf);
396: m_sb.append(m_insertionEndHtml);
397: }
398:
399: // .. has been removed
400: if (m_origBuf.length() > 0) {
401: m_sb.append(m_deletionStartHtml);
402: m_sb.append(m_origBuf);
403: m_sb.append(m_deletionEndHtml);
404: }
405:
406: // Do we want a "forward" link?
407: if (m_emitChangeNextPreviousHyperlinks
408: && (m_index < m_max)) {
409: m_sb.append(m_forwardPreIndex);
410: m_sb.append(m_count); // Has already been incremented.
411: m_sb.append(m_forwardPostIndex);
412: }
413:
414: m_sb.append(m_changeEndHtml);
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: }
|