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