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     */
019    package org.apache.wiki.plugin;
020    
021    import java.io.File;
022    import java.io.FileInputStream;
023    import java.io.FileOutputStream;
024    import java.io.IOException;
025    import java.io.InputStream;
026    import java.io.OutputStream;
027    import java.text.MessageFormat;
028    import java.util.Collection;
029    import java.util.Comparator;
030    import java.util.HashSet;
031    import java.util.Iterator;
032    import java.util.Map;
033    import java.util.Map.Entry;
034    import java.util.Properties;
035    import java.util.TreeMap;
036    
037    import org.apache.commons.io.IOUtils;
038    import org.apache.commons.lang.StringUtils;
039    import org.apache.commons.lang.math.NumberUtils;
040    import org.apache.log4j.Logger;
041    import org.apache.oro.text.GlobCompiler;
042    import org.apache.oro.text.regex.MalformedPatternException;
043    import org.apache.oro.text.regex.Pattern;
044    import org.apache.oro.text.regex.PatternCompiler;
045    import org.apache.oro.text.regex.PatternMatcher;
046    import org.apache.oro.text.regex.Perl5Matcher;
047    import org.apache.wiki.ReferenceManager;
048    import org.apache.wiki.WikiBackgroundThread;
049    import org.apache.wiki.WikiContext;
050    import org.apache.wiki.WikiEngine;
051    import org.apache.wiki.WikiPage;
052    import org.apache.wiki.api.exceptions.PluginException;
053    import org.apache.wiki.api.plugin.InitializablePlugin;
054    import org.apache.wiki.api.plugin.WikiPlugin;
055    import org.apache.wiki.event.WikiEngineEvent;
056    import org.apache.wiki.event.WikiEvent;
057    import org.apache.wiki.event.WikiEventListener;
058    import org.apache.wiki.event.WikiPageEvent;
059    import org.apache.wiki.event.WikiPageRenameEvent;
060    import 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     */
079    public 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    }