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 */
019package org.apache.wiki.plugin;
020
021import org.apache.log4j.Logger;
022import org.apache.wiki.api.core.Context;
023import org.apache.wiki.api.core.ContextEnum;
024import org.apache.wiki.api.core.Engine;
025import org.apache.wiki.api.core.Page;
026import org.apache.wiki.api.exceptions.PluginException;
027import org.apache.wiki.api.exceptions.ProviderException;
028import org.apache.wiki.api.plugin.ParserStagePlugin;
029import org.apache.wiki.api.plugin.Plugin;
030import org.apache.wiki.api.plugin.PluginElement;
031import org.apache.wiki.api.providers.WikiProvider;
032import org.apache.wiki.auth.AuthorizationManager;
033import org.apache.wiki.auth.permissions.PagePermission;
034import org.apache.wiki.pages.PageManager;
035import org.apache.wiki.preferences.Preferences;
036import org.apache.wiki.preferences.Preferences.TimeFormat;
037import org.apache.wiki.references.ReferenceManager;
038import org.apache.wiki.render.RenderingManager;
039import org.apache.wiki.util.TextUtil;
040
041import java.text.DateFormat;
042import java.text.MessageFormat;
043import java.text.ParseException;
044import java.text.SimpleDateFormat;
045import java.util.ArrayList;
046import java.util.Calendar;
047import java.util.Comparator;
048import java.util.Date;
049import java.util.Iterator;
050import java.util.List;
051import java.util.Map;
052import java.util.ResourceBundle;
053import java.util.Set;
054import java.util.regex.Matcher;
055import java.util.regex.Pattern;
056
057/**
058 *  <p>Builds a simple weblog.
059 *  The pageformat can use the following params:</p>
060 *  <p>%p - Page name</p>
061 *  <p>Parameters:</p>
062 *  <ul>
063 *    <li><b>page</b> - which page is used to do the blog; default is the current page.</li>
064 *    <li><b>entryFormat</b> - how to display the date on pages, using the J2SE SimpleDateFormat
065 *       syntax. Defaults to the current locale's DateFormat.LONG format
066 *       for the date, and current locale's DateFormat.SHORT for the time.
067 *       Thus, for the US locale this will print dates similar to
068 *       this: September 4, 2005 11:54 PM</li>
069 *    <li><b>days</b> - how many days the weblog aggregator should show.  If set to
070 *      "all", shows all pages.</li>
071 *    <li><b>pageformat</b> - What the entry pages should look like.</li>
072 *    <li><b>startDate</b> - Date when to start.  Format is "ddMMyy."</li>
073 *    <li><b>maxEntries</b> - How many entries to show at most.</li>
074 *    <li><b>preview</b> - How many characters of the text to show on the preview page.</li>
075 *  </ul>
076 *  <p>The "days" and "startDate" can also be sent in HTTP parameters,
077 *  and the names are "weblog.days" and "weblog.startDate", respectively.</p>
078 *  <p>The weblog plugin also adds an attribute to each page it is on:
079 *  "weblogplugin.isweblog" is set to "true".  This can be used to quickly
080 *  peruse pages which have weblogs.</p>
081 *  @since 1.9.21
082 */
083
084// FIXME: Add "entries" param as an alternative to "days".
085// FIXME: Entries arrive in wrong order.
086
087public class WeblogPlugin implements Plugin, ParserStagePlugin {
088
089    private static final Logger log = Logger.getLogger(WeblogPlugin.class);
090    private static final Pattern HEADINGPATTERN;
091
092    /** How many days are considered by default.  Default value is {@value} */
093    private static final int     DEFAULT_DAYS = 7;
094    private static final String  DEFAULT_PAGEFORMAT = "%p_blogentry_";
095
096    /** The default date format used in the blog entry page names. */
097    public static final String   DEFAULT_DATEFORMAT = "ddMMyy";
098
099    /** Parameter name for the startDate.  Value is <tt>{@value}</tt>. */
100    public static final String  PARAM_STARTDATE    = "startDate";
101    /** Parameter name for the entryFormat.  Value is <tt>{@value}</tt>. */
102    public static final String  PARAM_ENTRYFORMAT  = "entryFormat";
103    /** Parameter name for the days.  Value is <tt>{@value}</tt>. */
104    public static final String  PARAM_DAYS         = "days";
105    /** Parameter name for the allowComments.  Value is <tt>{@value}</tt>. */
106    public static final String  PARAM_ALLOWCOMMENTS = "allowComments";
107    /** Parameter name for the maxEntries.  Value is <tt>{@value}</tt>. */
108    public static final String  PARAM_MAXENTRIES   = "maxEntries";
109    /** Parameter name for the page.  Value is <tt>{@value}</tt>. */
110    public static final String  PARAM_PAGE         = "page";
111    /** Parameter name for the preview.  Value is <tt>{@value}</tt>. */
112    public static final String  PARAM_PREVIEW      = "preview";
113
114    /** The attribute which is stashed to the WikiPage attributes to check if a page
115     *  is a weblog or not. You may check for its presence.
116     */
117    public static final String  ATTR_ISWEBLOG      = "weblogplugin.isweblog";
118
119    static {
120        // This is a pretty ugly, brute-force regex. But it will do for now...
121        HEADINGPATTERN = Pattern.compile("(<h[1-4][^>]*>)(.*)(</h[1-4]>)", Pattern.CASE_INSENSITIVE);
122    }
123
124    /**
125     *  Create an entry name based on the blogname, a date, and an entry number.
126     *
127     *  @param pageName Name of the blog
128     *  @param date The date (in ddMMyy format)
129     *  @param entryNum The entry number.
130     *  @return A formatted page name.
131     */
132    public static String makeEntryPage( final String pageName, final String date, final String entryNum ) {
133        return TextUtil.replaceString(DEFAULT_PAGEFORMAT,"%p",pageName)+date+"_"+entryNum;
134    }
135
136    /**
137     *  Return just the basename for entires without date and entry numebr.
138     *
139     *  @param pageName The name of the blog.
140     *  @return A formatted name.
141     */
142    public static String makeEntryPage( final String pageName )
143    {
144        return TextUtil.replaceString(DEFAULT_PAGEFORMAT,"%p",pageName);
145    }
146
147    /**
148     *  Returns the entry page without the entry number.
149     *
150     *  @param pageName Blog name.
151     *  @param date The date.
152     *  @return A base name for the blog entries.
153     */
154    public static String makeEntryPage( final String pageName, final String date ) {
155        return TextUtil.replaceString(DEFAULT_PAGEFORMAT,"%p",pageName)+date;
156    }
157
158    /**
159     *  {@inheritDoc}
160     */
161    @Override
162    public String execute( final Context context, final Map< String, String > params ) throws PluginException {
163        final Calendar   startTime;
164        final Calendar   stopTime;
165        int        numDays = DEFAULT_DAYS;
166        final Engine engine = context.getEngine();
167        final AuthorizationManager mgr = engine.getManager( AuthorizationManager.class );
168
169        //
170        //  Parse parameters.
171        //
172        String days;
173        final DateFormat entryFormat;
174        String startDay;
175        boolean hasComments = false;
176        int maxEntries;
177        String weblogName;
178
179        if( (weblogName = params.get(PARAM_PAGE)) == null ) {
180            weblogName = context.getPage().getName();
181        }
182
183        if( (days = context.getHttpParameter( "weblog."+PARAM_DAYS )) == null ) {
184            days = params.get( PARAM_DAYS );
185        }
186
187        if( ( params.get(PARAM_ENTRYFORMAT)) == null ) {
188            entryFormat = Preferences.getDateFormat( context, TimeFormat.DATETIME );
189        } else {
190            entryFormat = new SimpleDateFormat( params.get(PARAM_ENTRYFORMAT) );
191        }
192
193        if( days != null ) {
194            if( days.equalsIgnoreCase("all") ) {
195                numDays = Integer.MAX_VALUE;
196            } else {
197                numDays = TextUtil.parseIntParameter( days, DEFAULT_DAYS );
198            }
199        }
200
201
202        if( (startDay = params.get(PARAM_STARTDATE)) == null ) {
203            startDay = context.getHttpParameter( "weblog."+PARAM_STARTDATE );
204        }
205
206        if( TextUtil.isPositive( params.get(PARAM_ALLOWCOMMENTS) ) ) {
207            hasComments = true;
208        }
209
210        maxEntries = TextUtil.parseIntParameter( params.get(PARAM_MAXENTRIES), Integer.MAX_VALUE );
211
212        //
213        //  Determine the date range which to include.
214        //
215        startTime = Calendar.getInstance();
216        stopTime  = Calendar.getInstance();
217
218        if( startDay != null ) {
219            final SimpleDateFormat fmt = new SimpleDateFormat( DEFAULT_DATEFORMAT );
220            try {
221                final Date d = fmt.parse( startDay );
222                startTime.setTime( d );
223                stopTime.setTime( d );
224            } catch( final ParseException e ) {
225                return "Illegal time format: "+startDay;
226            }
227        }
228
229        //
230        //  Mark this to be a weblog
231        //
232        context.getPage().setAttribute(ATTR_ISWEBLOG, "true");
233
234        //
235        //  We make a wild guess here that nobody can do millisecond accuracy here.
236        //
237        startTime.add( Calendar.DAY_OF_MONTH, -numDays );
238        startTime.set( Calendar.HOUR, 0 );
239        startTime.set( Calendar.MINUTE, 0 );
240        startTime.set( Calendar.SECOND, 0 );
241        stopTime.set( Calendar.HOUR, 23 );
242        stopTime.set( Calendar.MINUTE, 59 );
243        stopTime.set( Calendar.SECOND, 59 );
244
245        final StringBuilder sb = new StringBuilder();
246        final List< Page > blogEntries = findBlogEntries( engine, weblogName, startTime.getTime(), stopTime.getTime() );
247        blogEntries.sort( new PageDateComparator() );
248
249        sb.append("<div class=\"weblog\">\n");
250
251        for( final Iterator< Page > i = blogEntries.iterator(); i.hasNext() && maxEntries-- > 0 ; ) {
252            final Page p = i.next();
253            if( mgr.checkPermission( context.getWikiSession(), new PagePermission(p, PagePermission.VIEW_ACTION) ) ) {
254                addEntryHTML( context, entryFormat, hasComments, sb, p, params );
255            }
256        }
257
258        sb.append("</div>\n");
259
260        return sb.toString();
261    }
262
263    /**
264     *  Generates HTML for an entry.
265     *
266     *  @param context
267     *  @param entryFormat
268     *  @param hasComments  True, if comments are enabled.
269     *  @param buffer       The buffer to which we add.
270     *  @param entry
271     *  @throws ProviderException
272     */
273    private void addEntryHTML( final Context context, final DateFormat entryFormat, final boolean hasComments,
274                               final StringBuilder buffer, final Page entry, final Map< String, String > params) {
275        final Engine engine = context.getEngine();
276        final ResourceBundle rb = Preferences.getBundle(context, Plugin.CORE_PLUGINS_RESOURCEBUNDLE);
277
278        buffer.append("<div class=\"weblogentry\">\n");
279
280        //
281        //  Heading
282        //
283        buffer.append("<div class=\"weblogentryheading\">\n");
284
285        final Date entryDate = entry.getLastModified();
286        buffer.append( entryFormat != null ? entryFormat.format(entryDate) : entryDate );
287        buffer.append("</div>\n");
288
289        //
290        //  Append the text of the latest version.  Reset the context to that page.
291        //
292        final Context entryCtx = context.clone();
293        entryCtx.setPage( entry );
294
295        String html = engine.getManager( RenderingManager.class ).getHTML( entryCtx, engine.getManager( PageManager.class ).getPage( entry.getName() ) );
296
297        // Extract the first h1/h2/h3 as title, and replace with null
298        buffer.append("<div class=\"weblogentrytitle\">\n");
299        final Matcher matcher = HEADINGPATTERN.matcher( html );
300        if ( matcher.find() ) {
301            final String title = matcher.group(2);
302            html = matcher.replaceFirst("");
303            buffer.append( title );
304        } else {
305            buffer.append( entry.getName() );
306        }
307        buffer.append("</div>\n");
308        buffer.append("<div class=\"weblogentrybody\">\n");
309
310        final int preview = TextUtil.parseIntParameter(params.get(PARAM_PREVIEW), 0);
311        if (preview > 0) {
312            //
313            // We start with the first 'preview' number of characters from the text,
314            // and then add characters to it until we get to a linebreak or a period.
315            // The idea is that cutting off at a linebreak is less likely
316            // to disturb the HTML and leave us with garbled output.
317            //
318            boolean hasBeenCutOff = false;
319            int cutoff = Math.min(preview, html.length());
320            while (cutoff < html.length()) {
321                if (html.charAt(cutoff) == '\r' || html.charAt(cutoff) == '\n') {
322                    hasBeenCutOff = true;
323                    break;
324                } else if (html.charAt(cutoff) == '.') {
325                    // we do want the period
326                    cutoff++;
327                    hasBeenCutOff = true;
328                    break;
329                }
330                cutoff++;
331            }
332            buffer.append(html.substring(0, cutoff));
333            if (hasBeenCutOff) {
334                buffer.append(" <a href=\""+entryCtx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), entry.getName())+"\">"+rb.getString("weblogentryplugin.more")+"</a>\n");
335            }
336        } else {
337            buffer.append(html);
338        }
339        buffer.append("</div>\n");
340
341        //
342        //  Append footer
343        //
344        buffer.append("<div class=\"weblogentryfooter\">\n");
345
346        String author = entry.getAuthor();
347
348        if( author != null ) {
349            if( engine.getManager( PageManager.class ).wikiPageExists(author) ) {
350                author = "<a href=\""+entryCtx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), author )+"\">"+engine.getManager( RenderingManager.class ).beautifyTitle(author)+"</a>";
351            }
352        } else {
353            author = "AnonymousCoward";
354        }
355
356        buffer.append( MessageFormat.format( rb.getString("weblogentryplugin.postedby"), author));
357        buffer.append( "<a href=\"" + entryCtx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), entry.getName() ) + "\">" + rb.getString("weblogentryplugin.permalink") + "</a>" );
358        final String commentPageName = TextUtil.replaceString( entry.getName(), "blogentry", "comments" );
359
360        if( hasComments ) {
361            final int numComments = guessNumberOfComments( engine, commentPageName );
362
363            //
364            //  We add the number of comments to the URL so that the user's browsers would realize that the page has changed.
365            //
366            buffer.append( "&nbsp;&nbsp;" );
367
368            final String addcomment = rb.getString("weblogentryplugin.addcomment");
369
370            buffer.append( "<a href=\""+
371                           entryCtx.getURL( ContextEnum.PAGE_COMMENT.getRequestContext(), commentPageName, "nc=" + numComments ) + "\">" +
372                           MessageFormat.format( addcomment, numComments ) +
373                           "</a>" );
374        }
375
376        buffer.append("</div>\n");
377
378        //  Done, close
379        buffer.append("</div>\n");
380    }
381
382    private int guessNumberOfComments( final Engine engine, final String commentpage ) {
383        final String pagedata = engine.getManager( PageManager.class ).getPureText( commentpage, WikiProvider.LATEST_VERSION );
384        if( pagedata == null || pagedata.trim().length() == 0 ) {
385            return 0;
386        }
387
388        return TextUtil.countSections( pagedata );
389    }
390
391    /**
392     *  Attempts to locate all pages that correspond to the
393     *  blog entry pattern.  Will only consider the days on the dates; not the hours and minutes.
394     *
395     *  @param engine Engine which is used to get the pages
396     *  @param baseName The basename (e.g. "Main" if you want "Main_blogentry_xxxx")
397     *  @param start The date which is the first to be considered
398     *  @param end   The end date which is the last to be considered
399     *  @return a list of pages with their FIRST revisions.
400     */
401    public List< Page > findBlogEntries( final Engine engine, String baseName, final Date start, final Date end ) {
402        final PageManager mgr = engine.getManager( PageManager.class );
403        final Set< String > allPages = engine.getManager( ReferenceManager.class ).findCreated();
404        final ArrayList< Page > result = new ArrayList<>();
405
406        baseName = makeEntryPage( baseName );
407
408        for( final String pageName : allPages ) {
409            if( pageName.startsWith( baseName ) ) {
410                try {
411                    final Page firstVersion = mgr.getPageInfo( pageName, 1 );
412                    final Date d = firstVersion.getLastModified();
413
414                    if( d.after( start ) && d.before( end ) ) {
415                        result.add( firstVersion );
416                    }
417                } catch( final Exception e ) {
418                    log.debug( "Page name :" + pageName + " was suspected as a blog entry but it isn't because of parsing errors", e );
419                }
420            }
421        }
422
423        return result;
424    }
425
426    /**
427     *  Reverse comparison.
428     */
429    private static class PageDateComparator implements Comparator< Page > {
430
431        /**{@inheritDoc}*/
432        @Override
433        public int compare( final Page page1, final Page page2 ) {
434            if( page1 == null || page2 == null ) {
435                return 0;
436            }
437            return page2.getLastModified().compareTo( page1.getLastModified() );
438        }
439
440    }
441
442    /**
443     *  Mark us as being a real weblog.
444     *
445     *  {@inheritDoc}
446     */
447    @Override
448    public void executeParser( final PluginElement element, final Context context, final Map< String, String > params ) {
449        context.getPage().setAttribute( ATTR_ISWEBLOG, "true" );
450    }
451
452}