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 java.io.File;
022import java.io.FileInputStream;
023import java.io.FileOutputStream;
024import java.io.IOException;
025import java.io.InputStream;
026import java.io.OutputStream;
027import java.text.MessageFormat;
028import java.util.Collection;
029import java.util.Comparator;
030import java.util.HashSet;
031import java.util.Iterator;
032import java.util.Map;
033import java.util.Map.Entry;
034import java.util.Properties;
035import java.util.TreeMap;
036
037import org.apache.commons.io.IOUtils;
038import org.apache.commons.lang.StringUtils;
039import org.apache.commons.lang.math.NumberUtils;
040import org.apache.log4j.Logger;
041import org.apache.oro.text.GlobCompiler;
042import org.apache.oro.text.regex.MalformedPatternException;
043import org.apache.oro.text.regex.Pattern;
044import org.apache.oro.text.regex.PatternCompiler;
045import org.apache.oro.text.regex.PatternMatcher;
046import org.apache.oro.text.regex.Perl5Matcher;
047import org.apache.wiki.ReferenceManager;
048import org.apache.wiki.WikiBackgroundThread;
049import org.apache.wiki.WikiContext;
050import org.apache.wiki.WikiEngine;
051import org.apache.wiki.WikiPage;
052import org.apache.wiki.api.exceptions.PluginException;
053import org.apache.wiki.api.plugin.InitializablePlugin;
054import org.apache.wiki.api.plugin.WikiPlugin;
055import org.apache.wiki.event.WikiEngineEvent;
056import org.apache.wiki.event.WikiEvent;
057import org.apache.wiki.event.WikiEventListener;
058import org.apache.wiki.event.WikiPageEvent;
059import org.apache.wiki.event.WikiPageRenameEvent;
060import org.apache.wiki.util.TextUtil;
061
062
063/**
064 * This plugin counts the number of times a page has been viewed.<br/>
065 * Parameters:
066 * <ul>
067 * <li>count=yes|no</li>
068 * <li>show=none|count|list</li>
069 * <li>entries=maximum number of list entries to be returned</li>
070 * <li>min=minimum page count to be listed</li>
071 * <li>max=maximum page count to be listed</li>
072 * <li>sort=name|count</li>
073 * </ul>
074 * Default values:<br/>
075 * <code>show=none  sort=name</code>
076 * 
077 * @since 2.8
078 */
079public class PageViewPlugin extends AbstractReferralPlugin implements WikiPlugin, InitializablePlugin
080{
081    private static final Logger log = Logger.getLogger( PageViewPlugin.class );
082
083    /** The page view manager. */
084    private static PageViewManager c_singleton = null;
085
086    /** Constant for the 'count' parameter / value. */
087    private static final String PARAM_COUNT = "count";
088
089    /** Name of the 'entries' parameter. */
090    private static final String PARAM_MAX_ENTRIES = "entries";
091
092    /** Name of the 'max' parameter. */
093    private static final String PARAM_MAX_COUNT = "max";
094
095    /** Name of the 'min' parameter. */
096    private static final String PARAM_MIN_COUNT = "min";
097
098    /** Name of the 'refer' parameter. */
099    private static final String PARAM_REFER = "refer";
100
101    /** Name of the 'sort' parameter. */
102    private static final String PARAM_SORT = "sort";
103
104    /** Constant for the 'none' parameter value. */
105    private static final String STR_NONE = "none";
106
107    /** Constant for the 'list' parameter value. */
108    private static final String STR_LIST = "list";
109
110    /** Constant for the 'yes' parameter value. */
111    private static final String STR_YES = "yes";
112
113    /** Constant for empty string. */
114    private static final String STR_EMPTY = "";
115
116    /** Constant for Wiki markup separator. */
117    private static final String STR_SEPARATOR = "----";
118
119    /** Constant for comma-separated list separator. */
120    private static final String STR_COMMA = ",";
121
122    /** Constant for no-op glob expression. */
123    private static final String STR_GLOBSTAR = "*";
124
125    /** Constant for file storage. */
126    private static final String COUNTER_PAGE = "PageCount.txt";
127
128    /** Constant for storage interval in seconds. */
129    private static final int STORAGE_INTERVAL = 60;
130
131    /**
132     * Initialize the PageViewPlugin and its singleton.
133     * 
134     * @param engine The wiki engine.
135     */
136    public void initialize( WikiEngine engine )
137    {
138
139        log.info( "initializing PageViewPlugin" );
140
141        synchronized( this )
142        {
143            if( c_singleton == null )
144            {
145                c_singleton = new PageViewManager(  );
146            }
147            c_singleton.initialize( engine );
148        }
149    }
150
151    /**
152     * Cleanup the singleton reference.
153     */
154    private void cleanup()
155    {
156        log.info( "cleaning up PageView Manager" );
157
158        c_singleton = null;
159    }
160
161    /**
162     *  {@inheritDoc}
163     */
164    public String execute( WikiContext context, Map<String, String> params ) throws PluginException
165    {
166        PageViewManager manager = c_singleton;
167        String result = STR_EMPTY;
168
169        if( manager != null )
170        {
171            result = manager.execute( context, params );
172        }
173
174        return result;
175    }
176
177    /**
178     * Page view manager, handling all storage.
179     */
180    public final class PageViewManager implements WikiEventListener
181    {
182        /** Are we initialized? */
183        private boolean m_initialized = false;
184
185        /** The page counters. */
186        private Map<String, Counter> m_counters = null;
187
188        /** The page counters in storage format. */
189        private Properties m_storage = null;
190
191        /** Are all changes stored? */
192        private boolean m_dirty = false;
193
194        /** The page count storage background thread. */
195        private Thread m_pageCountSaveThread = null;
196
197        /** The work directory. */
198        private String m_workDir = null;
199
200        /** Comparator for descending sort on page count. */
201        private final Comparator<Object> m_compareCountDescending = new Comparator<Object>() {
202            public int compare( Object o1, Object o2 )
203            {
204                final int v1 = getCount( o1 );
205                final int v2 = getCount( o2 );
206                return (v1 == v2) ? ((String) o1).compareTo( (String) o2 ) : (v1 < v2) ? 1 : -1;
207            }
208        };
209
210        /**
211         * Initialize the page view manager.
212         * 
213         * @param engine The wiki engine.
214         */
215        public synchronized void initialize( WikiEngine engine )
216        {
217            log.info( "initializing PageView Manager" );
218
219            m_workDir = engine.getWorkDir();
220
221            engine.addWikiEventListener( this );
222
223            if( m_counters == null )
224            {
225                // Load the counters into a collection
226                m_storage = new Properties();
227                m_counters = new TreeMap<String, Counter>();
228
229                loadCounters();
230            }
231
232            // backup counters every 5 minutes
233            if( m_pageCountSaveThread == null )
234            {
235                m_pageCountSaveThread = new CounterSaveThread( engine, 5 * STORAGE_INTERVAL, this );
236                m_pageCountSaveThread.start();
237            }
238
239            m_initialized = true;
240        }
241
242        /**
243         * Handle the shutdown event via the page counter thread.
244         * 
245         */
246        private synchronized void handleShutdown()
247        {
248            log.info( "handleShutdown: The counter store thread was shut down." );
249
250            cleanup();
251
252            if( m_counters != null )
253            {
254
255                m_dirty = true;
256                storeCounters();
257
258                m_counters.clear();
259                m_counters = null;
260
261                m_storage.clear();
262                m_storage = null;
263            }
264
265            m_initialized = false;
266
267            m_pageCountSaveThread = null;
268        }
269
270        /**
271         * Inspect wiki events for shutdown.
272         * 
273         * @param event The wiki event to inspect.
274         */
275        public void actionPerformed( WikiEvent event )
276        {
277            if( event instanceof WikiEngineEvent )
278            {
279                if( event.getType() == WikiEngineEvent.SHUTDOWN )
280                {
281                    log.info( "Detected wiki engine shutdown" );
282                    handleShutdown();
283                }
284            } 
285            else if( (event instanceof WikiPageRenameEvent) && (event.getType() == WikiPageRenameEvent.PAGE_RENAMED) )
286            {
287                String oldPageName = ((WikiPageRenameEvent) event).getOldPageName();
288                String newPageName = ((WikiPageRenameEvent) event).getNewPageName();
289                Counter oldCounter = m_counters.get(oldPageName);
290                if ( oldCounter != null )
291                {
292                    m_storage.remove(oldPageName);
293                    m_counters.put(newPageName, oldCounter);
294                    m_storage.setProperty(newPageName, oldCounter.toString());
295                    m_counters.remove(oldPageName);
296                    m_dirty = true;
297                }
298            }
299            else if( (event instanceof WikiPageEvent) && (event.getType() == WikiPageEvent.PAGE_DELETED) )
300            {
301                 String pageName = ((WikiPageEvent) event).getPageName();
302                 m_storage.remove(pageName);
303                 m_counters.remove(pageName);
304            }
305        }
306
307        /**
308         * Count a page hit, present a pages' counter or output a list of page counts.
309         * 
310         * @param context the wiki context
311         * @param params the plugin parameters
312         * @return String Wiki page snippet
313         * @throws PluginException Malformed pattern parameter.
314         */
315        public String execute( WikiContext context, Map<String, String> params ) throws PluginException
316        {
317            WikiEngine engine = context.getEngine();
318            WikiPage page = context.getPage();
319            String result = STR_EMPTY;
320
321            if( page != null )
322            {
323                // get parameters
324                String pagename = page.getName();
325                String count = params.get( PARAM_COUNT );
326                String show = params.get( PARAM_SHOW );
327                int entries = TextUtil.parseIntParameter( params.get( PARAM_MAX_ENTRIES ), Integer.MAX_VALUE );
328                final int max = TextUtil.parseIntParameter( params.get( PARAM_MAX_COUNT ), Integer.MAX_VALUE );
329                final int min = TextUtil.parseIntParameter( params.get( PARAM_MIN_COUNT ), Integer.MIN_VALUE );
330                String sort = params.get( PARAM_SORT );
331                String body = params.get( DefaultPluginManager.PARAM_BODY );
332                Pattern[] exclude = compileGlobs( PARAM_EXCLUDE, params.get( PARAM_EXCLUDE ) );
333                Pattern[] include = compileGlobs( PARAM_INCLUDE, params.get( PARAM_INCLUDE ) );
334                Pattern[] refer = compileGlobs( PARAM_REFER, params.get( PARAM_REFER ) );
335                PatternMatcher matcher = (null != exclude || null != include || null != refer) ? new Perl5Matcher() : null;
336                boolean increment = false;
337
338                // increment counter?
339                if( STR_YES.equals( count ) )
340                {
341                    increment = true;
342                }
343                else
344                {
345                    count = null;
346                }
347
348                // default increment counter?
349                if( (show == null || STR_NONE.equals( show )) && count == null )
350                {
351                    increment = true;
352                }
353
354                // filter on referring pages?
355                Collection<String> referrers = null;
356
357                if( refer != null )
358                {
359                    ReferenceManager refManager = engine.getReferenceManager();
360
361                    Iterator< String > iter = refManager.findCreated().iterator();
362
363                    while ( iter != null && iter.hasNext() )
364                    {
365                        String name = iter.next();
366                        boolean use = false;
367
368                        for( int n = 0; !use && n < refer.length; n++ )
369                        {
370                            use = matcher.matches( name, refer[n] );
371                        }
372
373                        if( use )
374                        {
375                            Collection< String > refs = engine.getReferenceManager().findReferrers( name );
376
377                            if( refs != null && !refs.isEmpty() )
378                            {
379                                if( referrers == null )
380                                {
381                                    referrers = new HashSet<String>();
382                                }
383                                referrers.addAll( refs );
384                            }
385                        }
386                    }
387                }
388
389                synchronized( this )
390                {
391                    Counter counter = m_counters.get( pagename );
392
393                    // only count in view mode, keep storage values in sync
394                    if( increment && WikiContext.VIEW.equalsIgnoreCase( context.getRequestContext() ) )
395                    {
396                        if( counter == null )
397                        {
398                            counter = new Counter();
399                            m_counters.put( pagename, counter );
400                        }
401                        counter.increment();
402                        m_storage.setProperty( pagename, counter.toString() );
403                        m_dirty = true;
404                    }
405
406                    if( show == null || STR_NONE.equals( show ) )
407                    {
408                        // nothing to show
409
410                    }
411                    else if( PARAM_COUNT.equals( show ) )
412                    {
413                        // show page count
414                        if( counter == null )
415                        {
416                            counter = new Counter();
417                            m_counters.put( pagename, counter );
418                            m_storage.setProperty( pagename, counter.toString() );
419                            m_dirty = true;
420                        }
421                        result = counter.toString();
422
423                    }
424                    else if( body != null && 0 < body.length() && STR_LIST.equals( show ) )
425                    {
426                        // show list of counts
427                        String header = STR_EMPTY;
428                        String line = body;
429                        String footer = STR_EMPTY;
430                        int start = body.indexOf( STR_SEPARATOR );
431
432                        // split body into header, line, footer on ----
433                        // separator
434                        if( 0 < start )
435                        {
436                            header = body.substring( 0, start );
437
438                            start = skipWhitespace( start + STR_SEPARATOR.length(), body );
439
440                            int end = body.indexOf( STR_SEPARATOR, start );
441
442                            if( start >= end )
443                            {
444                                line = body.substring( start );
445
446                            }
447                            else
448                            {
449                                line = body.substring( start, end );
450
451                                end = skipWhitespace( end + STR_SEPARATOR.length(), body );
452
453                                footer = body.substring( end );
454                            }
455                        }
456
457                        // sort on name or count?
458                        Map<String, Counter> sorted = m_counters;
459
460                        if( sort != null && PARAM_COUNT.equals( sort ) )
461                        {
462                            sorted = new TreeMap<String, Counter>( m_compareCountDescending );
463
464                            sorted.putAll( m_counters );
465                        }
466
467                        // build a messagebuffer with the list in wiki markup
468                        StringBuffer buf = new StringBuffer( header );
469                        MessageFormat fmt = new MessageFormat( line );
470                        Object[] args = new Object[] { pagename, STR_EMPTY, STR_EMPTY };
471                        Iterator< Entry< String, Counter > > iter = sorted.entrySet().iterator();
472
473                        while ( iter != null && 0 < entries && iter.hasNext() )
474                        {
475                            Entry< String, Counter > entry = iter.next();
476                            String name = entry.getKey();
477
478                            // check minimum/maximum count
479                            final int value = entry.getValue().getValue();
480                            boolean use = min <= value && value <= max;
481
482                            // did we specify a refer-to page?
483                            if( use && referrers != null )
484                            {
485                                use = referrers.contains( name );
486                            }
487
488                            // did we specify what pages to include?
489                            if( use && include != null )
490                            {
491                                use = false;
492
493                                for( int n = 0; !use && n < include.length; n++ )
494                                {
495                                    use = matcher.matches( name, include[n] );
496                                }
497                            }
498
499                            // did we specify what pages to exclude?
500                            if( use && null != exclude )
501                            {
502                                for( int n = 0; use && n < exclude.length; n++ )
503                                {
504                                    use &= !matcher.matches( name, exclude[n] );
505                                }
506                            }
507
508                            if( use )
509                            {
510                                args[1] = engine.beautifyTitle( name );
511                                args[2] = entry.getValue();
512
513                                fmt.format( args, buf, null );
514
515                                entries--;
516                            }
517                        }
518                        buf.append( footer );
519
520                        // let the engine render the list
521                        result = engine.textToHTML( context, buf.toString() );
522                    }
523                }
524            }
525            return result;
526        }
527
528        /**
529         * Compile regexp parameter.
530         * 
531         * @param name The name of the parameter.
532         * @param value The parameter value.
533         * @return Pattern[] The compiled patterns, or <code>null</code>.
534         * @throws PluginException On malformed patterns.
535         */
536        private Pattern[] compileGlobs( String name, String value ) throws PluginException
537        {
538            Pattern[] result = null;
539
540            if( value != null && 0 < value.length() && !STR_GLOBSTAR.equals( value ) )
541            {
542                try
543                {
544                    PatternCompiler pc = new GlobCompiler();
545
546                    String[] ptrns = StringUtils.split( value, STR_COMMA );
547
548                    result = new Pattern[ptrns.length];
549
550                    for( int n = 0; n < ptrns.length; n++ )
551                    {
552                        result[n] = pc.compile( ptrns[n] );
553                    }
554                }
555                catch( MalformedPatternException e )
556                {
557                    throw new PluginException( "Parameter " + name + " has a malformed pattern: " + e.getMessage() );
558                }
559            }
560
561            return result;
562        }
563
564        /**
565         * Adjust offset skipping whitespace.
566         * 
567         * @param offset The offset in value to adjust.
568         * @param value String in which offset points.
569         * @return int Adjusted offset into value.
570         */
571        private int skipWhitespace( int offset, String value )
572        {
573            while ( Character.isWhitespace( value.charAt( offset ) ) )
574            {
575                offset++;
576            }
577            return offset;
578        }
579
580        /**
581         * Retrieve a page count.
582         * 
583         * @return int The page count for the given key.
584         * @param key the key for the Counter
585         */
586        protected int getCount( Object key )
587        {
588            return m_counters.get( key ).getValue();
589        }
590
591        /**
592         * Load the page view counters from file.
593         */
594        private void loadCounters()
595        {
596            if( m_counters != null && m_storage != null )
597            {
598                log.info( "Loading counters." );
599                synchronized( this )
600                {
601                    InputStream fis = null;
602                    try
603                    {
604                        fis = new FileInputStream( new File( m_workDir, COUNTER_PAGE ) );
605                        m_storage.load( fis );
606                    }
607                    catch( IOException ioe )
608                    {
609                        log.error( "Can't load page counter store: " + ioe.getMessage() + " , will create a new one!" );
610                    }
611                    finally
612                    {
613                        IOUtils.closeQuietly( fis );
614                    }
615
616                    // Copy the collection into a sorted map
617                    Iterator< Entry< Object, Object > > iter = m_storage.entrySet().iterator();
618
619                    while ( iter != null && iter.hasNext() )
620                    {
621                        Entry< ?, ? > entry = iter.next();
622
623                        m_counters.put( (String) entry.getKey(), new Counter( (String) entry.getValue() ) );
624                    }
625                    
626                    log.info( "Loaded " + m_counters.size() + " counter values." );
627                }
628            }
629        }
630
631        /**
632         * Save the page view counters to file.
633         * 
634         */
635        protected void storeCounters()
636        {
637            if( m_counters != null && m_storage != null && m_dirty )
638            {
639                log.info( "Storing " + m_counters.size() + " counter values." );
640
641                synchronized( this )
642                {
643                    OutputStream fos = null;
644
645                    // Write out the collection of counters
646                    try
647                    {
648                        fos = new FileOutputStream( new File( m_workDir, COUNTER_PAGE ) );
649
650                        m_storage.store( fos, "\n# The number of times each page has been viewed.\n# Do not modify.\n" );
651                        fos.flush();
652
653                        m_dirty = false;
654                    }
655                    catch( IOException ioe )
656                    {
657                        log.error( "Couldn't store counters values: " + ioe.getMessage() );
658                    }
659                    finally
660                    {
661                        IOUtils.closeQuietly( fos );
662                    }
663                }
664            }
665        }
666
667        /**
668         * Is the given thread still current?
669         * 
670         * @return boolean <code>true</code> if the thread is still the current
671         *         background thread.
672         * @param thrd
673         */
674        private synchronized boolean isRunning( Thread thrd )
675        {
676            return m_initialized && thrd == m_pageCountSaveThread;
677        }
678
679    }
680
681
682    /**
683     * Counter for page hits collection.
684     */
685    private static final class Counter
686    {
687
688        /** The count value. */
689        private int m_count = 0;
690
691        /**
692         * Create a new counter.
693         */
694        public Counter()
695        {
696        }
697
698        /**
699         * Create and initialize a new counter.
700         * 
701         * @param value Count value.
702         */
703        public Counter( String value )
704        {
705            setValue( value );
706        }
707
708        /**
709         * Increment counter.
710         */
711        public void increment()
712        {
713            m_count++;
714        }
715
716        /**
717         * Get the count value.
718         * 
719         * @return int
720         */
721        public int getValue()
722        {
723            return m_count;
724        }
725
726        /**
727         * Set the count value.
728         * 
729         * @param value String representation of the count.
730         */
731        public void setValue( String value )
732        {
733            m_count = NumberUtils.toInt( value );
734        }
735
736        /**
737         * @return String String representation of the count.
738         */
739        public String toString()
740        {
741            return String.valueOf( m_count );
742        }
743    }
744
745    /**
746     * Background thread storing the page counters.
747     */
748    static final class CounterSaveThread extends WikiBackgroundThread
749    {
750
751        /** The page view manager. */
752        private final PageViewManager m_manager;
753
754        /**
755         * Create a wiki background thread to store the page counters.
756         * 
757         * @param engine The wiki engine.
758         * @param interval Delay in seconds between saves.
759         * @param pageViewManager
760         */
761        public CounterSaveThread( WikiEngine engine, int interval, PageViewManager pageViewManager )
762        {
763
764            super( engine, interval );
765
766            if( pageViewManager == null )
767            {
768                throw new IllegalArgumentException( "Manager cannot be null" );
769            }
770
771            m_manager = pageViewManager;
772        }
773
774        /**
775         * Save the page counters to file.
776         */
777        public void backgroundTask()
778        {
779
780            if( m_manager.isRunning( this ) )
781            {
782                m_manager.storeCounters();
783            }
784        }
785    }
786}