header()
* and $theme->footer()
* --
* Include this file in header:
* http://path/to/habari/scripts/clickheat.js
* And put this in the footer:
*
*/
class Clickheat extends Plugin
{
private $screenSizes = array(
0 => 'Full',
640 => 640,
800 => 800,
1024 => 1024,
1280 => 1280,
1440 => 1440,
1600 => 1600,
1800 => 1800
);
private $groups;
private $is_clickheat= false;
private $logs, $cache;
/**
* Plugin info
*/
public function info()
{
return array(
'name' => 'Clickheat',
'version' => '1.0',
'url' => 'http://www.benhutchins.com/project/habari-clickheat/',
'author' => 'Benjamin Hutchins',
'authorurl' => 'http://www.benhutchins.com',
'license' => 'MIT License',
'description' => 'Generate clickheat graphs based off your visitors.'
);
}
/**
* Initialize by added directory variables
*/
public function action_init()
{
$this->logs= dirname(__FILE__) . '/logs';
$this->cache= dirname(__FILE__) . '/cache';
if ( ! $this->confirm_dirs($error) ) {
Session::error( "Clickheat error: $error" );
Plugins::deactivate_plugin(__FILE__); // Deactivate plugin
Utils::redirect(); //Refresh page
exit();
}
}
/**
* Add default options when plugin is activated
*/
public function action_plugin_activation( $file )
{
if ( realpath( $file ) == __FILE__ ) {
Options::set( 'clickheat__week_start_on_monday', false );
Options::set( 'clickheat__yesterday', false );
Options::set( 'clickheat__quota', -1 );
Options::set( 'clickheat__wait', 500 );
Options::set( 'clickheat__memory', 16 );
Options::set( 'clickheat__palette', false );
Options::set( 'clickheat__dot_size', 19 );
Options::set( 'clickheat__step', 5 );
Options::set( 'clickheat__heatmap', true );
Options::set( 'clickheat__rainbow', true );
Options::set( 'clickheat__flush', 40 );
}
}
/**
* Add configure tab to action list
*/
public function filter_plugin_config( $actions, $plugin_id )
{
if ( $plugin_id == $this->plugin_id() )
$actions[]= _t( 'Configure' );
return $actions;
}
/**
* Create the configuration FromUI
*/
public function action_plugin_ui( $plugin_id, $action )
{
if ( $plugin_id == $this->plugin_id() )
{
switch ( $action ) {
case _t( 'Configure' ):
$ui= new FormUI( strtolower( get_class( $this ) ) );
$ui->append( 'checkbox', 'heatmap', 'option:clickheat__heatmap', _t( 'Use heatmap by default?' ));
$ui->append( 'checkbox', 'week_start_on_monday', 'option:clickheat__week_start_on_monday', _t( 'Weeks start on Monday?' ));
$ui->append( 'checkbox', 'yesterday', 'option:clickheat__yesterday', _t( 'Show yesterday\'s stats by default?' ));
$ui->append( 'text', 'quota', 'option:clickheat__quota', _t( 'Click quota per person, per page (-1 = unlimited)' ));
$ui->append( 'text', 'wait', 'option:clickheat__wait', _t( 'Wait time after click to verify success' ));
$ui->append( 'text', 'memory', 'option:clickheat__memory', _t( 'Memory limit in megabytes' ));
$ui->append( 'checkbox', 'palette', 'option:clickheat__palette', _t( 'If you see red squares on heatmaps check this box' ));
$ui->append( 'text', 'dot_size', 'option:clickheat__dot_size', _t( 'Heatmap dot size (pixels)' ));
$ui->append( 'text', 'step', 'option:clickheat__step', _t( 'Group clicks within X*X pixel zones (speed up display of heatmaps)' ));
$ui->append( 'checkbox', 'rainbow', 'option:clickheat__rainbow', _t( 'Show rainbow click count in heatmaps?' ));
$ui->append( 'text', 'flush', 'option:clickheat__flush', _t( 'Automatic flush of statistics older than X days (0 = keep all files, not recommended)' ));
$ui->append( 'submit', 'save', 'Save' );
$ui->out();
break;
}
}
}
/**
* Add rewrite rules
*/
public function filter_rewrite_rules( $db_rules )
{
// clickheat admin javascript
$db_rules[] = new RewriteRule ( array(
'name' => 'clickheat__admin_js',
'parse_regex' => '%^3rdparty/clickheat\.js$%i',
'build_str' => '3rdparty/clickheat.js',
'handler' => 'UserThemeHandler',
'action' => 'clickheat_admin_js',
'priority' => 7,
'is_active' => 1,
'rule_class' => RewriteRule::RULE_CUSTOM,
'description' => 'Clickheat: Admin JavaScript Output'
) );
// clickheat stylesheet
$db_rules[] = new RewriteRule ( array(
'name' => 'clickheat__admin_js',
'parse_regex' => '%^3rdparty/clickheat\.css$%i',
'build_str' => 'scripts/clickheat.css',
'handler' => 'UserThemeHandler',
'action' => 'clickheat_css',
'priority' => 7,
'is_active' => 1,
'rule_class' => RewriteRule::RULE_CUSTOM,
'description' => 'Clickheat: Stylesheet Output'
) );
// clickheat theme javascript
$db_rules[] = new RewriteRule ( array(
'name' => 'clickheat__js',
'parse_regex' => '%^scripts/clickheat\.js$%i',
'build_str' => 'scripts/clickheat.js',
'handler' => 'UserThemeHandler',
'action' => 'clickheat_js',
'priority' => 7,
'is_active' => 1,
'rule_class' => RewriteRule::RULE_CUSTOM,
'description' => 'Clickheat: JavaScript Output'
) );
return $db_rules;
}
/**
* Here we add a menu item for clickheat
* Position it after "Logs"
*/
public function filter_adminhandler_post_loadplugins_main_menu( $mainmenus ) {
$menu= array();
foreach($mainmenus as $k=>$m) {
$menu[$k]= $m;
if ( $k == 'logs' )
$menu['clickheat'] = array(
'url' => URL::get( 'admin', 'page=clickheat' ),
'title' => _t( 'Clickheat' ),
'text' => _t( 'Clickheat' ),
'hotkey' => 'C',
'selected' => false
);
}
return $menu;
}
/**
* Send out theme header and footer addons
*/
public function action_template_header()
{
Stack::add('template_header_javascript', Site::get_url( 'scripts' ) . '/clickheat.js', 'clickheat');
}
public function action_template_footer()
{
echo "";
}
/**
* Get list of available groups
*/
private function load_groups()
{
$groups = array();
// Get all groups
$glob = Utils::glob( $this->logs . '/*/url.xml' );
foreach( $glob as $file ) {
$dir= dirname( $file );
$dir= substr($dir, strrpos($dir, "/")+1); // remove full path
$xml= simplexml_load_file( $file );
$groups[ $dir ] = "{$xml->title} ({$xml->url})";
}
$this->groups= $groups;
}
/**
* Create an HTML table calendar for date selection
*/
private function create_calendar()
{
$weekStartOnMonday= Options::get( 'clickheat__week_start_on_monday' );
$cal = '
';
$days = explode(',', 'M,T,W,T,F,S,S');
for ($d = 0; $d < 7; $d++)
{
$D = $d + ($weekStartOnMonday ? 0 : 6);
if ($D > 6)
$D -= 7;
$cal .= '| '.$days[$D].' | ';
}
$cal .= '
';
$before = date('w', mktime(0, 0, 0, $this->month, 1, $this->year)) - ($weekStartOnMonday ? 1 : 0);
if ($before < 0)
$before += 7;
$this->lastDayOfMonth = date('t', mktime(0, 0, 0, $this->month - 1, 1, $this->year));
for ($d = 1; $d <= $before; $d++)
$cal .= '| '.($this->lastDayOfMonth - $before + $d).' | ';
$cols = $before - 1;
$this->js = 'var weekDays = [';
for ($d = 1, $days = date('t', $this->date); $d <= $days; $d++) {
$D = mktime(0, 0, 0, $this->month, $d, $this->year);
if (++$cols === 7) {
$cal .= '
';
$cols = 0;
}
$cal .= '| '.$d.' | ';
$this->js .= ','.(date('W', $D) + (date('w', $D) == 0 && !$weekStartOnMonday ? 1 : 0));
}
$this->js .= '];';
for ($d = 1; $cols < 6; $cols++, $d++)
$cal .= ''.$d.' | ';
$cal .= '
';
return $cal;
}
/**
* Handle admin header and footer
*/
public function action_admin_header()
{
if ( $this->is_clickheat ) {
Stack::add('admin_header_javascript', Site::get_url( 'habari' ) . '/3rdparty/clickheat.js', 'clickheat');
Stack::add('admin_stylesheet', array(Site::get_url( 'habari' ) . '/3rdparty/clickheat.css', 'screen'), 'clickheat');
}
}
public function action_admin_footer()
{
if ( $this->is_clickheat )
echo "";
}
/**
* Handle clickheat page
*/
public function action_admin_theme_post_clickheat( $handler, $theme )
{
$this->action_admin_theme_get_clickheat( $handler, $theme );
}
public function action_admin_theme_get_clickheat( $handler, $theme )
{
$vars= $handler->handler_vars;
$this->is_clickheat= true;
$this->theme= $theme;
// load required items from logs
$this->load_groups();
// confirm group count
if ( count($this->groups) == 0 ) {
$this->is_clickheat= false;
$theme->display( 'header' );
echo "" . _t("No logs exist. Please wait until someone clicks somewhere.") . "
";
$theme->display( 'footer' );
exit();
}
// Date
$this->date = isset($vars['date']) ? strtotime($vars['date']) : (Options::get( 'clickheat__yesterday' ) ? mktime(0, 0, 0, date('m'), date('d') - 1, date('Y')) : false);
if ($this->date === false)
$this->date = time();
$this->day = (int) date('d', $this->date);
$this->month = (int) date('m', $this->date);
$this->year = (int) date('Y', $this->date);
$option_fields= array(
'group' => array(
'label' => _t('Group'),
'type' => 'select',
'selectarray' => $this->groups,
),
'screen' => array(
'label' => _t('Screen Size'),
'type' => 'select',
'selectarray' => $this->screenSizes
),
'heatmap' => array(
'label' => _t('Heatmap'),
'type' => 'checkbox',
'value' => Options::get( 'clickheat__heatmap' )
),
);
$form = new FormUI('Clickheat View');
foreach ( $option_fields as $option_name => $option ) {
$field = $form->append( $option['type'], $option_name, $option_name, $option['label'] );
$field->template = 'optionscontrol_' . $option['type'];
$field->class = 'item clear nomargin';
if ( $option['type'] == 'select' && isset( $option['selectarray'] ) ) {
$field->options = $option['selectarray'];
} else {
$field->value= $option['value'];
}
}
$field = $form->append('static', 'alpha', '
');
$theme->form= $form->get();
require "view.php";
exit();
}
/**
* Send out stylesheet for admin
*/
public function filter_theme_act_clickheat_css()
{
header("Content-Type: text/css");
require dirname(__FILE__) . "/clickheat.css";
exit();
}
/**
* Send out clickheat for admin
*/
public function filter_theme_act_clickheat_admin_js()
{
header("Content-Type: text/javascript");
echo "habari.url.clickheat='" . URL::get('ajax', 'context=clickheat') . "';\n";
require dirname(__FILE__) . "/js/admin.js";
exit();
}
/**
* Sent out clickheat for users
*/
public function filter_theme_act_clickheat_js()
{
header("Content-Type: text/javascript");
require dirname(__FILE__) . "/js/clickheat.js";
exit();
}
/**
* Make sure log directories exist
*/
private function confirm_dirs( &$error, $dir = null, $vars = array() )
{
// make sure logs directory exists
if ( !is_dir( $this->logs ) ) {
if ( !mkdir( $this->logs) ) {
$error= _t("Logs directory doesn't exist and cannot be created. Please create the directory: {$this->logs}");
return false;
}
}
// make sure logs is writeable
if ( ! is_writeable( $this->logs ) ) {
$error= _t("Logs directory is not writable, please give write ability to: {$this->logs}");
return false;
}
// make sure cache directory exists
if ( !is_dir( $this->cache ) ) {
if ( !mkdir( $this->cache ) ) {
$error= _t("Cache directory doesn't exist and cannot be created. Please create the directory: {$this->cache}");
return false;
}
}
// make sure cache is writeable
if ( ! is_writeable( $this->logs ) ) {
$error= _t("Cache directory is not writable, please give write ability to: {$this->cache}");
return false;
}
// make sure we're not just checking the logs directory
if ( $dir === null ) return true;
// make sure group directory exists
if ( !is_dir( $this->logs . "/$dir" ) ) {
if ( !mkdir($this->logs . "/$dir" ) ) {
$error= _t("Cannot create log directory: {$this->logs}/{$dir}");
return false;
}
}
// Create the url.xml
$xml= new SimpleXMLElement('');
$xml->addChild('url', $vars['href']);
$xml->addChild('title', $vars['title']);
// Save the url.xml
$f = fopen( "{$this->logs}/{$dir}/url.xml", 'w' );
fputs($f, $xml->asXML());
fclose($f);
return true;
}
/**
* Handle all Ajax requests
*/
public function action_ajax_clickheat( $handler )
{
$vars= $handler->handler_vars;
switch( $vars['action'] )
{
case 'click':
// Check parameters
if (!isset($vars['x']) || !isset($vars['y']) || !isset($vars['w']) || !isset($vars['href']) || !isset($vars['title']) || !isset($vars['which']))
exit('Parameters or config error');
// Format href to a directory name
$dir= preg_replace("/^http(s)?\:\/\/(www)?(\.)?/", "", strtolower($vars['href'])); // remove http(s)://www.
$dir= explode("?", $dir); $dir= $dir[0]; // remove query string
$dir= explode("#", $dir); $dir= $dir[0]; // remove URL fragments before grouping, thanks moeffju
// TODO: Probably should change these to substrings and not explode.
$dir= rtrim(preg_replace('/[^a-z_0-9\-]+/', '.', $dir), "."); // remove odd characters
// Confirm directories
$status= $this->confirm_dirs( $error, $dir, $vars );
if ( $status === false ) exit ( $error );
// Log the click
$f = fopen($this->logs . "/$dir/" . date('Y-m-d') . '.log', 'a');
fputs($f, ((int) $vars['x']) . '|' . ((int) $vars['y']) . '|' . ((int) $vars['w']) . "|" . ((int) $vars['which']) . "\n");
fclose($f);
echo 'success';
break;
case 'cleaner':
echo $this->cleaner();
break;
case 'generate':
echo $this->generate( $vars );
break;
case 'iframe':
if ( !isset( $vars['group'] ) ) break;
$group= $this->logs . '/' . $vars['group'];
if ( !is_dir( $group ) ) break;
$file= "$group/url.xml";
if ( file_exists( $file ) ) {
$xml= simplexml_load_file( $file );
echo $xml->url;
} else {
echo "/";
}
break;
case 'png':
$imagePath= $this->cache . "/" . (isset($vars['file']) ? $vars['file'] : '**unknown**');
header('Content-Type: image/png');
readfile( file_exists($imagePath) ? $imagePath : (dirname(__FILE__) . '/warning.png') );
break;
}
exit();
}
/**
* Generate image
*/
private function generate( $vars )
{
/**
* Class files
*/
include dirname(__FILE__) . '/classes/Heatmap.class.php';
include dirname(__FILE__) . '/classes/HeatmapFromClicks.class.php';
/**
* Screen size
*/
$screen = isset($vars['screen']) ? (int) $vars['screen'] : 0;
$minScreen = 0;
if ($screen < 0) {
$width = abs($screen);
$maxScreen = 3000;
} else {
$maxScreen = $screen;
if (!in_array($screen, $this->screenSizes) || $screen === 0)
$this->error( _t('Non-standard screen size') . ": $screen" );
$psize= 0;
foreach($this->screenSizes as $size) {
if ($size === $screen) {
$minScreen = $psize;
break;
}
$psize= $size;
}
$width = $screen - 25;
}
/**
* Time and memory limits
*/
$memory= Options::get( 'clickheat__memory' );
@set_time_limit(120);
@ini_set('memory_limit', $memory . 'M');
/**
* Selected Group
*/
$group = isset($vars['group']) ? $vars['group'] : false;
if ( $group === false || !is_dir( $this->logs . "/" . $group) )
return $this->error( _t('Unknown group') );
/**
* Show clicks or heatmap
*/
$heatmap = (isset($vars['heatmap']) && $vars['heatmap'] == 1);
/**
* Date and days
*/
$time = isset($_SERVER['REQUEST_TIME']) ? $_SERVER['REQUEST_TIME'] : time();
$dateStamp = isset($vars['date']) ? strtotime($vars['date']) : $time;
$range = isset($vars['range']) && in_array($vars['range'], array('d', 'w', 'm')) ? $vars['range'] : 'd';
$date = date('Y-m-d', $dateStamp);
switch ($range) {
case 'd':
$days = 1;
$delay = date('dmy', $dateStamp) !== date('dmy') ? 86400 : 120;
break;
case 'w':
$days = 7;
$delay = date('Wy', $dateStamp) !== date('Wy') ? 86400 : 120;
break;
case 'm':
$days = date('t', $dateStamp);
$delay = date('my', $dateStamp) !== date('my') ? 86400 : 120;
break;
}
$imagePath = $group.'-'.$date.'-'.$range.'-'.$screen.'-'.'-'.($heatmap ? 'heat' : 'click');
$htmlPath = $this->cache . "/" . $imagePath.'.html';
/**
* If images are already created,
* just stop script here if these have less
* than 120 seconds (today's log) or 86400 seconds (old logs)
*/
if (file_exists($htmlPath) && filemtime($htmlPath) > $time - $delay) {
readfile($htmlPath);
exit();
}
/**
* Call the Heatmap class
*/
$clicksHeatmap = new HeatmapFromClicks();
$clicksHeatmap->minScreen = $minScreen;
$clicksHeatmap->maxScreen = $maxScreen;
$clicksHeatmap->memory = $memory * 1048576;
$clicksHeatmap->step = Options::get( 'clickheat__step' );
$clicksHeatmap->dot = Options::get( 'clickheat__dot_size' );
$clicksHeatmap->palette = Options::get( 'clickheat__palette' );
$clicksHeatmap->rainbow = Options::get( 'clickheat__rainbow' );
$clicksHeatmap->heatmap = $heatmap;
$clicksHeatmap->path = $this->cache;
$clicksHeatmap->cache = $this->cache;
$clicksHeatmap->file = $imagePath.'-%d.png';
/**
* Add files
*/
for ($day = 0; $day < $days; $day++) {
$currentDate = date('Y-m-d', mktime(0, 0, 0, date('m', $dateStamp), date('d', $dateStamp) + $day, date('Y', $dateStamp)));
$clicksHeatmap->addFile($this->logs . "/$group/$currentDate.log");
}
if ( ( $result = $clicksHeatmap->generate($width) ) === false)
return $this->error( $clicksHeatmap->error );
$html = '';
for ($i = 0; $i < $result['count']; $i++)
$html .= ') . ')
';
/**
* Save the HTML code to speed up following queries (only over two minutes)
*/
$f = fopen($htmlPath, 'w');
fputs($f, $html);
fclose($f);
return $html;
}
/**
* Delete old logs
*/
private function cleaner()
{
$deletedFiles = 0;
$deletedDirs = 0;
/**
* Clean the logs' directory according to configuration data
*/
$flush= Options::get( 'clickheat__flush' );
if ($flush >= 0)
{
$logs= Utils::glob( $this->logs . "/*/*.log" );
$oldestDate= mktime(0, 0, 0, date('m'), date('d') - $flush, date('Y'));
$deletedAll= array();
foreach($logs as $log) {
// dont process .htaccess
if (count(explode('.', $log)) !== 2)
continue;
$dir= dirname( $log );
if ( !isset( $deletedAll[ $dir ] ) )
$deletedAll[ $dir ]= true;
if (filemtime($log) <= $oldestDate) {
@unlink($log);
$deletedFiles++;
continue;
}
$deletedAll[$dir]= false;
}
/**
* If every log file (but the url.txt) has been deleted,
* then we should delete the directory too
*/
foreach ($deletedAll as $dir=>$do) {
if ($do === true) {
@unlink( $dir . '/url.xml');
$deletedFiles++;
@rmdir($dir);
$deletedDirs++;
}
}
}
/**
* Clean the cache directory for every file older than 2 minutes
* 2 minutes is to make sure images are kept up to date with all
* new clicks.
*/
$glob= Utils::glob($this->cache . "/*");
foreach( $glob as $file ) {
$ext = explode('.', $file);
// dont process .htaccess
if (count($ext) !== 2)
continue;
$filemtime = filemtime($file);
switch ($ext[1])
{
case 'html':
case 'png':
case 'png_temp':
case 'png_log':
if ($filemtime + 86400 < time()) {
@unlink($file);
$deletedFiles++;
}
}
}
// Did we delete anything?
if ($deletedDirs + $deletedFiles === 0)
return 'OK';
else
return sprintf(_t("Cleaning finished: %d files and %d directories have been deleted"), $deletedFiles, $deletedDirs);
}
/**
* Put an error in some HTML
*/
private function error( $msg )
{
return "$msg";
}
}
?>