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