/* PageView - a plugin based on PageViewCountPlugin for: JSPWiki - a JSP-based WikiWiki clone. */ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.text.MessageFormat; import java.util.Collection; import java.util.Comparator; import java.util.HashSet; import java.util.Iterator; import java.util.Map; import java.util.Properties; import java.util.TreeMap; import java.util.Map.Entry; import com.ecyrd.jspwiki.ReferenceManager; import com.ecyrd.jspwiki.TextUtil; import com.ecyrd.jspwiki.WikiContext; import com.ecyrd.jspwiki.WikiEngine; import com.ecyrd.jspwiki.WikiPage; import com.ecyrd.jspwiki.event.WikiEngineEvent; import com.ecyrd.jspwiki.event.WikiEvent; import com.ecyrd.jspwiki.event.WikiEventListener; import com.ecyrd.jspwiki.plugin.InitializablePlugin; import com.ecyrd.jspwiki.plugin.PluginException; import com.ecyrd.jspwiki.plugin.PluginManager; import com.ecyrd.jspwiki.plugin.WikiPlugin; import com.ecyrd.jspwiki.util.WikiBackgroundThread; import org.apache.commons.lang.StringUtils; import org.apache.log4j.Logger; import org.apache.oro.text.GlobCompiler; import org.apache.oro.text.regex.MalformedPatternException; import org.apache.oro.text.regex.Pattern; import org.apache.oro.text.regex.PatternCompiler; import org.apache.oro.text.regex.PatternMatcher; import org.apache.oro.text.regex.Perl5Matcher; /** * Counts the number of times each page has been viewed. * * Parameters: count=yes|no * show=none|count|list * entries=max number of list entries * min=minimum page count to be listed * sort=name|count * * Default values are show=none and sort=name. * * @author Andre van dalen * @version 0.5 */ public class PageViewPlugin implements WikiPlugin, InitializablePlugin { /** The logger. */ private static Logger log = Logger.getLogger(PageViewPlugin.class); /** The page view manager. */ private static PageViewManager singleton = null; /** Constant for the 'count' parameter / value. */ private static final String PARAM_COUNT = "count"; /** Name of the 'entries' parameter. */ private static final String PARAM_MAX_ENTRIES = "entries"; /** Name of the 'exclude' parameter. */ private static final String PARAM_EXCLUDE = "exclude"; /** Name of the 'include' parameter. */ private static final String PARAM_INCLUDE = "include"; /** Name of the 'max' parameter. */ private static final String PARAM_MAX_COUNT = "max"; /** Name of the 'min' parameter. */ private static final String PARAM_MIN_COUNT = "min"; /** Name of the 'refer' parameter. */ private static final String PARAM_REFER = "refer"; /** Name of the 'show' parameter. */ private static final String PARAM_SHOW = "show"; /** Name of the 'sort' parameter. */ private static final String PARAM_SORT = "sort"; /** Constant for the 'none' parameter value. */ private static final String STR_NONE = "none"; /** Constant for the 'list' parameter value. */ private static final String STR_LIST = "list"; /** Constant for the 'name' parameter value. */ //private static final String STR_NAME = "name"; /** Constant for the 'yes' parameter value. */ private static final String STR_YES = "yes"; /** Constant for empty string. */ private static final String STR_EMPTY = ""; /** Constant for Wiki markup separator. */ private static final String STR_SEPARATOR = "----"; /** Constant for comma-separated list separator. */ private static final String STR_COMMA = ","; /** Constant for no-op glob exression. */ private static final String STR_GLOBSTAR = "*"; /** Constant for file storage. */ private static final String COUNTER_PAGE = "PageCount.txt"; /** Constant for storage interval in seconds. */ private static final int STORAGE_INTERVAL = 60; /** * Initialize the PageViewPlugin and its singleton. * @param engine The wiki engine. */ public void initialize(WikiEngine engine) { log.info("initialize"); if (null == singleton) { singleton = new PageViewManager(log); singleton.initialize(engine); } } /** * Cleanup the singleton reference. */ public void cleanup() { log.info("cleanup"); singleton = null; } /** * Count a page hit, present a pages' counter or output a list of * pagecounts. * * @param context * @param params * @throws com.ecyrd.jspwiki.plugin.PluginException * @return String Wiki page snippet * @concurrency concurrent */ public String execute(WikiContext context, Map params) throws PluginException { PageViewManager manager = singleton; String result = STR_EMPTY; if (null != manager) { result = manager.execute(context, params); } return result; } /** * Page view manager, handling all storage. */ public final class PageViewManager implements WikiEventListener { /** The logger. */ private final Logger log; /** Are we initialized? */ private boolean initialized = false; /** The page counters. */ private Map counters = null; /** The page counters in storage format. */ private Properties storage = null; /** Are all changes stored? */ private boolean dirty = false; /** The page count storage background thread. */ private Thread pageCountSaveThread = null; /** The work directory. */ private String workDir = null; /** Comparator for descending sort on page count. */ private final Comparator compareCountDescending = new Comparator() { public int compare(Object o1, Object o2) { final int v1 = getCount(o1); final int v2 = getCount(o2); return (v1 == v2) ? ((String) o1).compareTo((String) o2) : (v1 < v2) ? 1 : -1; } }; /** * Create a page view manager. * @param logger */ public PageViewManager(Logger logger) { log = logger; } /** * Initialize the page view manager. * @param engine The wiki engine. * @concurrency guarded */ public synchronized void initialize(WikiEngine engine) { log.info("initialize manager"); initialized = true; workDir = engine.getWorkDir(); engine.addWikiEventListener(this); if (null == counters) { // Load the counters into a collection storage = new Properties(); counters = new TreeMap(); loadCounters(); } // backup counters every 5 minutes if (null == pageCountSaveThread) { pageCountSaveThread = new CounterSaveThread(engine, 5 * STORAGE_INTERVAL, this); pageCountSaveThread.start(); } } /** * Handle the shutdown event via the page counter thread. * @concurrency guarded */ private synchronized void handleShutdown() { log.info("handleShutdown: The counter store thread was shut down."); cleanup(); if (null != counters) { dirty = true; storeCounters(); counters.clear(); counters = null; storage.clear(); storage = null; } initialized = false; pageCountSaveThread = null; } /** * Inspect wiki events for shutdown. * @param event The wiki event to inspect. */ public void actionPerformed(WikiEvent event) { if (event instanceof WikiEngineEvent) { if (event.getType() == WikiEngineEvent.SHUTDOWN) { log.info("actionPerformed: Detected wiki engine shutdown"); handleShutdown(); } } } /** * Count a page hit, present a pages' counter or output a list of * pagecounts. * * @param context * @param params * @throws com.ecyrd.jspwiki.plugin.PluginException * @return String Wiki page snippet * @throws PluginException Malformed pattern parameter. * @concurrency concurrent */ public String execute(WikiContext context, Map params) throws PluginException { WikiEngine engine = context.getEngine(); WikiPage page = context.getPage(); String result = STR_EMPTY; if (null != page) { // get parameters String pagename = page.getName(); String count = (String) params.get(PARAM_COUNT); String show = (String) params.get(PARAM_SHOW); int entries = TextUtil.parseIntParameter((String) params.get(PARAM_MAX_ENTRIES), Integer.MAX_VALUE); final int max = TextUtil.parseIntParameter((String) params.get(PARAM_MAX_COUNT), Integer.MAX_VALUE); final int min = TextUtil.parseIntParameter((String) params.get(PARAM_MIN_COUNT), Integer.MIN_VALUE); String sort = (String) params.get(PARAM_SORT); String body = (String) params.get(PluginManager.PARAM_BODY); Pattern[] exclude = compileGlobs(PARAM_EXCLUDE, (String) params.get(PARAM_EXCLUDE)); Pattern[] include = compileGlobs(PARAM_INCLUDE, (String) params.get(PARAM_INCLUDE)); Pattern[] refer = compileGlobs(PARAM_REFER, (String) params.get(PARAM_REFER)); PatternMatcher matcher = (null != exclude || null != include || null != refer) ? new Perl5Matcher() : null; boolean increment = false; // increment counter? if (STR_YES.equals(count)) { increment = true; } else { count = null; } // default increment counter? if ((null == show || STR_NONE.equals(show)) && null == count) { increment = true; } // filter on referring pages? Collection referrers = null; if (null != refer) { ReferenceManager refManager = engine.getReferenceManager(); Iterator iter = refManager.findCreated().iterator(); while (null != iter && iter.hasNext()) { String name = (String) iter.next(); boolean use = false; for (int n = 0; !use && n < refer.length; n++) { use = matcher.matches(name, refer[n]); } if (use) { Collection refs = engine.getReferenceManager().findReferrers(name); if (null != refs && !refs.isEmpty()) { if (null == referrers) { referrers = new HashSet(); } referrers.addAll(refs); } } } } synchronized (this) { Counter counter = (Counter) counters.get(pagename); // only count in view mode, keep storage values in sync if (increment && WikiContext.VIEW.equalsIgnoreCase(context.getRequestContext())) { if (null == counter) { counter = new Counter(); counters.put(pagename, counter); } counter.increment(); storage.setProperty(pagename, counter.toString()); dirty = true; } if (null == show || STR_NONE.equals(show)) { // nothing to show } else if (PARAM_COUNT.equals(show)) { // show page count result = counter.toString(); } else if (null != body && 0 < body.length() && STR_LIST.equals(show)) { // show list of counts String header = STR_EMPTY; String line = body; String footer = STR_EMPTY; int start = body.indexOf(STR_SEPARATOR); // split body into header, line, footer on ---- separator if (0 < start) { header = body.substring(0, start); start = skipWhitespace(start + STR_SEPARATOR.length(), body); int end = body.indexOf(STR_SEPARATOR, start); if (start >= end) { line = body.substring(start); } else { line = body.substring(start, end); end = skipWhitespace(end + STR_SEPARATOR.length(), body); footer = body.substring(end); } } // sort on name or count? Map sorted = counters; if (null != sort && PARAM_COUNT.equals(sort)) { sorted = new TreeMap(compareCountDescending); sorted.putAll(counters); } // build a messagebuffer with the list in wiki markup StringBuffer buf = new StringBuffer(header); MessageFormat fmt = new MessageFormat(line); Object[] args = new Object[] { pagename, STR_EMPTY, STR_EMPTY }; Iterator iter = sorted.entrySet().iterator(); while (null != iter && 0 < entries && iter.hasNext()) { Entry entry = (Entry) iter.next(); String name = (String) entry.getKey(); // check minimum count final int value = ((Counter) entry.getValue()).getValue(); boolean use = min <= value && value <= max; // did we specify a refer-to page? if (use && null != referrers) { use = referrers.contains(name); } // did we specify what pages to include? if (use && null != include) { use = false; for (int n = 0; !use && n < include.length; n++) { use = matcher.matches(name, include[n]); } } // did we specify what pages to exclude? if (use && null != exclude) { for (int n = 0; use && n < exclude.length; n++) { use &= !matcher.matches(name, exclude[n]); } } if (use) { args[1] = engine.beautifyTitle(name); args[2] = entry.getValue(); fmt.format(args, buf, null); entries--; } } buf.append(footer); // let the engine render the list result = engine.textToHTML(context, buf.toString()); } } } return result; } /** * Compile regexp parameter. * @param name The name of the parameter. * @param value The parameter value. * @return Pattern[] The compiled patterns, or null. * @throws PluginException On malformed patterns. */ private Pattern[] compileGlobs(String name, String value) throws PluginException { Pattern[] result = null; if (null != value && 0 < value.length() && !STR_GLOBSTAR.equals(value)) { try { PatternCompiler pc = new GlobCompiler(); String[] ptrns = StringUtils.split(value, STR_COMMA); result = new Pattern[ptrns.length]; for (int n = 0; n < ptrns.length; n++) { result[n] = pc.compile(ptrns[n]); } } catch (MalformedPatternException e) { throw new PluginException("Parameter " + name + " has a malformed pattern: " + e.getMessage()); } } return result; } /** * Adjust ofsset skipping whitespace. * * @param offset The offset in value to adjust. * @param value String in which offset points. * @return int Adjusted offset into value. */ private int skipWhitespace(int offset, String value) { while (Character.isWhitespace(value.charAt(offset))) { offset++; } return offset; } /** * Retrieve a page count. * @return int The page count for the given key. * @pre counters != null * @pre counters.get(key) != null * @param key */ protected int getCount(Object key) { return ((Counter) counters.get(key)).getValue(); } /** * Load the page view counters from file. */ private void loadCounters() { if (null != counters && null != storage) { if (log.isInfoEnabled()) { log.info("loadCounters"); } synchronized (this) { InputStream fis = null; try { fis = new FileInputStream(new File(workDir, COUNTER_PAGE)); storage.load(fis); } catch (IOException ioe) { log.error("loadCounters: Can't load page counter store, will create a new one: " + ioe.getMessage()); } finally { try { if (null != fis) { fis.close(); } } catch (Exception ignore) { /** ignore */ } } // Copy the collection into a sorted map Iterator iter = storage.entrySet().iterator(); while (null != iter && iter.hasNext()) { Entry entry = (Entry) iter.next(); counters.put(entry.getKey(), new Counter((String) entry.getValue())); } if (log.isInfoEnabled()) { log.info("loadCounters: counters.size=" + counters.size()); } } } } /** * Save the page view counters to file. * @concurrency $none */ protected void storeCounters() { if (null != counters && null != storage && dirty) { if (log.isInfoEnabled()) { log.info("storeCounters: counters.size=" + counters.size()); } synchronized (this) { OutputStream fos = null; // Write out the collection of counters try { fos = new FileOutputStream(new File(workDir, COUNTER_PAGE)); storage.store(fos, "\n# The number of times each page has been viewed.\n# Do not modify.\n"); fos.flush(); dirty = false; } catch (IOException ioe) { log.error("storeCounters: Can't store counters: " + ioe.getMessage()); } finally { try { if (null != fos) { fos.close(); } } catch (Exception ignore) { /**ignore*/ } } } } } /** * Is the given thread still current? * @return boolean true iff the thread is still the current background thread. * @param thrd */ protected boolean isRunning(Thread thrd) { return initialized && thrd == pageCountSaveThread; } } /** * Counter for page hits collection. */ static final class Counter { /** The count value. */ private int count = 0; /** * Create a new counter. */ public Counter() { } /** * Create and initialise a new counter. * @param value Count value. */ public Counter(String value) { setValue(value); } /** * Increment counter. */ public void increment() { count++; } /** * Get the count value. * @return int */ public int getValue() { return count; } /** * Set the count value. * @param value String representation of the count. */ public void setValue(String value) { try { count = Integer.parseInt(value); } catch (Exception ignore) { count = 0; } } /** * @return String String representation of the count. */ public String toString() { return String.valueOf(count); } } /** * Background thread storing the page counters. */ static final class CounterSaveThread extends WikiBackgroundThread { /** The page view manager. */ private final PageViewManager manager; /** * Create a wiki background thread to store the page counters. * @param engine The wiki engine. * @param interval Delay in seconds between saves. * @param pageViewManager */ public CounterSaveThread(WikiEngine engine, int interval, PageViewManager pageViewManager) { super(engine, interval); if (null == pageViewManager) { throw new IllegalArgumentException("Manager cannot be null"); } manager = pageViewManager; } /** * Save the page counters to file. */ public void backgroundTask() { if (manager.isRunning(this)) { manager.storeCounters(); } } } }