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.
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                }
325                cutoff++;
326            }
327            buffer.append(html.substring(0, cutoff));
328            if (hasBeenCutOff) {
329                buffer.append(" <a href=\""+entryCtx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), entry.getName())+"\">"+rb.getString("weblogentryplugin.more")+"</a>\n");
330            }
331        } else {
332            buffer.append(html);
333        }
334        buffer.append("</div>\n");
335
336        //
337        //  Append footer
338        //
339        buffer.append("<div class=\"weblogentryfooter\">\n");
340
341        String author = entry.getAuthor();
342
343        if( author != null ) {
344            if( engine.getManager( PageManager.class ).wikiPageExists(author) ) {
345                author = "<a href=\""+entryCtx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), author )+"\">"+engine.getManager( RenderingManager.class ).beautifyTitle(author)+"</a>";
346            }
347        } else {
348            author = "AnonymousCoward";
349        }
350
351        buffer.append( MessageFormat.format( rb.getString( "weblogentryplugin.postedby" ), author ) );
352        buffer.append( "<a href=\"" + entryCtx.getURL( ContextEnum.PAGE_VIEW.getRequestContext(), entry.getName() ) + "\">" + rb.getString("weblogentryplugin.permalink") + "</a>" );
353        final String commentPageName = TextUtil.replaceString( entry.getName(), "blogentry", "comments" );
354
355        if( hasComments ) {
356            final int numComments = guessNumberOfComments( engine, commentPageName );
357
358            //
359            //  We add the number of comments to the URL so that the user's browsers would realize that the page has changed.
360            //
361            buffer.append( "&nbsp;&nbsp;" );
362
363            final String addcomment = rb.getString("weblogentryplugin.addcomment");
364
365            buffer.append( "<a href=\""+
366                           entryCtx.getURL( ContextEnum.PAGE_COMMENT.getRequestContext(), commentPageName, "nc=" + numComments ) + "\">" +
367                           MessageFormat.format( addcomment, numComments ) +
368                           "</a>" );
369        }
370
371        buffer.append("</div>\n");
372
373        //  Done, close
374        buffer.append("</div>\n");
375    }
376
377    private int guessNumberOfComments( final Engine engine, final String commentpage ) {
378        final String pagedata = engine.getManager( PageManager.class ).getPureText( commentpage, WikiProvider.LATEST_VERSION );
379        if( pagedata == null || pagedata.trim().length() == 0 ) {
380            return 0;
381        }
382
383        return TextUtil.countSections( pagedata );
384    }
385
386    /**
387     *  Attempts to locate all pages that correspond to the
388     *  blog entry pattern.  Will only consider the days on the dates; not the hours and minutes.
389     *
390     *  @param engine Engine which is used to get the pages
391     *  @param baseName The basename (e.g. "Main" if you want "Main_blogentry_xxxx")
392     *  @param start The date which is the first to be considered
393     *  @param end   The end date which is the last to be considered
394     *  @return a list of pages with their FIRST revisions.
395     */
396    public List< Page > findBlogEntries( final Engine engine, String baseName, final Date start, final Date end ) {
397        final PageManager mgr = engine.getManager( PageManager.class );
398        final Set< String > allPages = engine.getManager( ReferenceManager.class ).findCreated();
399        final ArrayList< Page > result = new ArrayList<>();
400
401        baseName = makeEntryPage( baseName );
402
403        for( final String pageName : allPages ) {
404            if( pageName.startsWith( baseName ) ) {
405                try {
406                    final Page firstVersion = mgr.getPageInfo( pageName, 1 );
407                    final Date d = firstVersion.getLastModified();
408
409                    if( d.after( start ) && d.before( end ) ) {
410                        result.add( firstVersion );
411                    }
412                } catch( final Exception e ) {
413                    log.debug( "Page name :" + pageName + " was suspected as a blog entry but it isn't because of parsing errors", e );
414                }
415            }
416        }
417
418        return result;
419    }
420
421    /**
422     *  Reverse comparison.
423     */
424    private static class PageDateComparator implements Comparator< Page > {
425
426        /**{@inheritDoc}*/
427        @Override
428        public int compare( final Page page1, final Page page2 ) {
429            if( page1 == null || page2 == null ) {
430                return 0;
431            }
432            return page2.getLastModified().compareTo( page1.getLastModified() );
433        }
434
435    }
436
437    /**
438     *  Mark us as being a real weblog.
439     *
440     *  {@inheritDoc}
441     */
442    @Override
443    public void executeParser( final PluginElement element, final Context context, final Map< String, String > params ) {
444        context.getPage().setAttribute( ATTR_ISWEBLOG, "true" );
445    }
446
447}