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