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}