# Plugin for TWiki Collaboration Platform, http://TWiki.org/
#
# Copyright (C) 2005-2006 Michael Daum
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details, published at
# http://www.gnu.org/copyleft/gpl.html
# TODO don't calculate votes unless needed
# TODO implement $default for each type
#
###############################################################################
package TWiki::Plugins::VotePlugin::Core;
###############################################################################
use vars qw($debug $isInitialized $pubUrlPath);
use strict;
use Digest::MD5 qw(md5_base64);
use Fcntl qw(:flock);
use CGI;
$debug = 0; # toggle me
###############################################################################
sub handleVote {
my ($session, $params, $topic, $web) = @_;
unless ($pubUrlPath) {
$pubUrlPath = TWiki::Func::getPubUrlPath().'/'.TWiki::Func::getTwikiWebname().'/VotePlugin';
}
TWiki::Func::addToHEAD('VotePlugin_STARS', <
HEAD
my $defaults = TWiki::Func::getPreferencesValue('VOTEPLUGIN_DEFAULTS')
|| '';
while ($defaults =~ s/^\s*(\w+)=\"(.*?)\"//) {
$params->{$1} = $2 unless defined $params->{$1};
}
my $id = defined($params->{id}) ? $params->{id} : '_default';
my $isGlobal = isTrue($params->{global}, 0);
my $isOpen = isTrue($params->{open}, 1);
my $isSecret = isTrue($params->{secret}, 1);
my $bayesian = isTrue($params->{bayesian}, 0);
my $submit = isTrue($params->{submit}, 1);
my $saveto = $params->{saveto};
if (defined TWiki::Func::getCgiQuery()->param('register_vote')) {
registerVote($web, $topic, $id);
} else {
#print STDERR "no register_vote\n";
}
my @prompts = ();
my $defaultStarsFormat =
'| $desc | $smallScore: $score, My vote: $mylast, Total votes: $sum |';
my $defaultSelectFormat = '| $desc | $prompt | $bars |';
my $defaultChartFormat = '
$bar(300) $option $perc% ($score)
';
my $defaultTextFormat = '| $desc | $prompt |';
if (defined($params->{"starsformat"})) {
$defaultStarsFormat = $params->{"starsformat"};
}
if (defined($params->{"selectformat"})) {
$defaultSelectFormat = $params->{"selectformat"};
}
if (defined($params->{"chartformat"})) {
$defaultChartFormat = $params->{"chartformat"};
}
if (defined($params->{"textformat"})) {
$defaultTextFormat = $params->{"textformat"};
}
if (defined($params->{style})) {
# Compatibility
my $format = '';
if ($params->{style} =~ /perc/) {
$format .= '$perc% ';
}
if ($params->{style} =~ /total/) {
$format .= '($freq)';
}
if ($params->{style} =~ /sum/) {
$format .= '$sum votes';
}
$defaultSelectFormat = $format;
}
my $separator = $params->{separator};
$separator = "\n" unless defined $separator;
# Compatibility
if (defined($params->{select})) {
push(@prompts, {
type => 'select',
name => expandFormattingTokens($params->{select}),
format => $defaultSelectFormat,
options =>
[ map { expandFormattingTokens($_) }
split(/\s*,\s*/, $params->{options} || '') ]});
}
my $ifFactory;
for ( my $n = 1; 1; $n++ ) {
if (defined($params->{"enabled$n"})) {
unless( $ifFactory ) {
require TWiki::If;
$ifFactory = new TWiki::If();
}
my $expr = $ifFactory->parse( $params->{"enabled$n"} );
return inlineError("Error in enabled$n expression: " .
$ifFactory->{error} ) unless $expr;
if( ! $expr->evaluate( $session ) ) {
next;
}
}
if (defined($params->{"hidden$n"})) {
push(@prompts, {
type => 'hidden',
name => expandFormattingTokens($params->{"hidden$n"}),
value => expandFormattingTokens($params->{"default$n"})});
} elsif (defined($params->{"select$n"})) {
my $name = expandFormattingTokens($params->{"select$n"});
my $desc = expandFormattingTokens($params->{"desc$n"}) || $name;
push(@prompts, {
type => 'select',
name => $name, desc => $desc,
format => $params->{"format$n"} || $defaultSelectFormat,
chart => $params->{"chart$n"} || $defaultChartFormat,
options =>
[ map { expandFormattingTokens($_) }
split(/\s*,\s*/, $params->{"options$n"} || '') ]});
} elsif (defined($params->{"stars$n"})) {
unless (($params->{"width$n"} || 5) =~ /^\d+$/) {
return inlineError("Expected integer width for stars$n=");
}
my $name = expandFormattingTokens($params->{"stars$n"});
my $desc = expandFormattingTokens($params->{"desc$n"}) || $name;
push(@prompts, {
type => 'stars',
name => $name, desc => $desc,
format => $params->{"format$n"} || $defaultStarsFormat,
width => $params->{"width$n"} || 5 });
} elsif (defined($params->{"text$n"})) {
unless (($params->{"width$n"} || 30) =~ /^\d+$/) {
return inlineError("Expected integer width for text$n=");
}
my $name = expandFormattingTokens($params->{"text$n"});
my $desc = expandFormattingTokens($params->{"desc$n"}) || $name;
push(@prompts, {
type => 'text',
name => $name, desc => $desc,
format => $params->{"format$n"} || $defaultTextFormat,
width => $params->{"width$n"} || 30 });
} elsif (defined($params->{"textbox$n"})) {
unless (($params->{"width$n"} || 30) =~ /^\d+$/) {
return inlineError("Expected integer width for text$n=");
}
unless (($params->{"height$n"} || 5) =~ /^\d+$/) {
return inlineError("Expected integer height for text$n=");
}
my $name = expandFormattingTokens($params->{"textbox$n"});
my $desc = expandFormattingTokens($params->{"desc$n"}) || $name;
push(@prompts, {
type => 'textbox',
name => $name, desc => $desc,
format => $params->{"format$n"} || $defaultTextFormat,
width => $params->{"width$n"} || 30,
height => $params->{"height$n"} || 5 });
} else {
last;
}
}
# check attributes
if (!scalar(@prompts)) {
return inlineError("no prompts specified ".$params->stringify());
}
# read in the votes
my $lines = getVoteData($web, $topic, $id, $isGlobal, $saveto);
my %votes;
sub decode {
return chr(hex($_[0]))
}
my %lastVote;
foreach my $line (split/\r?\n/, $lines) {
if ($line =~ /^\|(.*)\|$/) {
my @data = split(/\|/, $1);
my $vid = $data[0];
my $voter = $data[1];
my $weight = $data[2];
foreach my $item (split(/,/, $data[3] || '')) {
if ($item =~ /^(.+)=(.+)$/) {
my ($row, $choice) = ($1, $2);
$choice =~ s/\\([0-9a-fA-F]{2})/decode($1)/eg;
$votes{$voter}{$vid}{$row} = [ $choice, $weight ];
}
}
} elsif (!$saveto && $line =~ /^([^\|]+)\|([^\|]+)\|(.*?)\|(.+)$/) {
# Old format - compatibility only
my $voter = $2;
my $weight = $3;
my $data = $4;
foreach my $item (split(/\|/, $data)) {
if ($item =~ /^(.+)=(.+)$/) {
my ($row, $choice) = ($1, $2);
$votes{$voter}{$id}{$row} = [ $choice, $weight ];
}
}
}
}
# Terminology:
# An =id= is the identifier of a %VOTE
# A =key= is the identifier of a vote row e.g. stars="" or select=""
# A =choice= is the identified of one of the options in a key
# Collect statistics. This is complicated by the fact that we
# have top level keys (represented by $key) which can receive
# a rating for lines of stars, and also individual values in a
# select, each of which has its own frequency. For the purposes
# of this analysis, a line of stars is treated as having a single
# leaf value, so the frequency of that value should be the same as the
# total number of votes for the key.
my %keyValueFreq; # frequency of a specific value for a given key
my %totalVotes; # total votes for a given key
my %totalVoters; # how many different people voted for each key
my %totalRate; # Total of all ratings for each key
my %items; # Hash of id's that have the same key
my $voteSum = 0; # Sum of the number of votes on all rated items
my $rateSum = 0; # Sum of all ratings of rated items
foreach my $voter (keys %votes) {
foreach my $vid (keys %{$votes{$voter}}) {
foreach my $key (keys %{$votes{$voter}{$vid}}) {
my $choice = $votes{$voter}{$vid}{$key}->[0];
my $weight = $votes{$voter}{$vid}{$key}->[1];
$keyValueFreq{$vid}{$key}{$choice} += $weight;
$totalVotes{$key} += $weight;
$items{$key}{$vid} = 1;
$voteSum += $weight;
if ($choice =~ /^[\d.]+$/) {
$totalRate{$key} += $choice * $weight;
$rateSum += $choice * $weight;
}
$totalVoters{$key}++;
}
}
}
# Do we need a submit button?
# SMELL text always needs submit
my $needSubmit = scalar(@prompts) > 1;
my $act;
if ($isOpen) {
$act = TWiki::Func::getScriptUrl($web, $topic, 'view');
} else {
$act = TWiki::Func::getScriptUrl($web, $topic, 'viewauth');
}
my $hiddens;
my @rows;
my %myVotes;
%myVotes = %{ $votes{getIdent($isSecret, $isOpen)}{$id} }
if defined $votes{getIdent($isSecret, $isOpen)}{$id};
foreach my $prompt (@prompts) {
my $key = $prompt->{name};
my $row;
if ($prompt->{type} eq 'stars') {
$hiddens .= CGI::input({type=>'hidden',
name=>$prompt->{name}, value=>$prompt->{value}})
} elsif ($prompt->{type} eq 'stars') {
my $numItems = scalar(keys(%{$items{$key}}));
# avg_num_votes: The average number of votes of all items that have
# num_votes>0
# avg_rating: The average rating of each item (again, of those that
# have num_votes>0)
my $avg_num_votes = $numItems ? $voteSum / $numItems : 0;
my $avg_rating = $voteSum ? $rateSum / $voteSum : 0;
my $myLastVote = $myVotes{$key}->[0] || 0;
my $mean = 0;
if ($totalVotes{$key}) {
$mean = $totalRate{$key} / $totalVotes{$key};
if ($bayesian) {
$mean = ($avg_num_votes * $avg_rating +
$totalVotes{$key} * $mean) /
($avg_num_votes + $totalVotes{$key});
}
}
push(@rows, showLineOfStars(
$id, $prompt, $submit, $needSubmit, $act,
$mean, $myLastVote, $totalVoters{$key} || 0));
} elsif ($prompt->{type} eq 'select') {
my $myLastVote = $myVotes{$key}->[0];
my $opts;
my $select;
if ($submit) {
if (! $myLastVote) {
$opts = CGI::option({selected=>'selected',
value=>''}, 'Select ...');
}
foreach my $optionName (@{$prompt->{options}}) {
if ($optionName eq $myLastVote) {
$opts .= CGI::option({selected=>'selected',
value=>$optionName}, $optionName);
} else {
$opts .= CGI::option($optionName);
}
}
my $o = { name => 'voteplugin_'.$key, size => 1 };
unless ($needSubmit) {
$o->{onchange} = 'javacript: submit()';
}
$select = CGI::Select($o, $opts);
} else {
$select = CGI::textfield({name=>'voteplugin_'.$key,default=>$myLastVote});
}
push(@rows, showSelect(
$id, $prompt, $myLastVote, $select, $keyValueFreq{$id}{$key},
$totalVotes{$id}{$key}, $params));
} elsif ($prompt->{type} eq 'text') {
my $myLastVote = $myVotes{$key}->[0] || '';
my $txt = CGI::textfield({name=>'voteplugin_'.$key,
size=>$prompt->{width}, maxlength=>1000, default=>$myLastVote});
# TODO all text and author display for comment list
push(@rows, showText($prompt, $myLastVote, $txt));
} elsif ($prompt->{type} eq 'textbox') {
my $myLastVote = $myVotes{$key}->[0] || '';
my $txt = CGI::textarea({name=>'voteplugin_'.$key,
columns=>$prompt->{width}, rows=>$prompt->{height},
default=>$myLastVote});
# TODO all text and author display for comment list
push(@rows, showText($prompt, $myLastVote, $txt));
}
}
my $result = join($separator, @rows);
if (defined $params->{thanks} && defined %{ $votes{getIdent($isSecret, $isOpen)}{$id} }) {
$result = expandFormattingTokens($params->{thanks}) . $separator . $result;
}
if ($submit && $needSubmit) {
$result .= "\n".CGI::submit(
{ name=> 'OK', value=>'OK',
class=> 'twikiSubmit', style=>'color:green'});
}
if ($submit) {
$hiddens .=
CGI::input({type=>'hidden',
name=>'register_vote', value=>$id})
. CGI::input({type=>'hidden',
name=>'isGlobal', value=>$isGlobal})
. CGI::input({type=>'hidden',
name=>'isSecret', value=>$isSecret})
. CGI::input({type=>'hidden',
name=>'isOpen', value=>$isOpen})
. CGI::input({type=>'hidden',
name=>'saveTo', value=>$saveto});
$result = "";
}
return $result;
}
###############################################################################
sub registerVote {
my ($web, $topic, $id) = @_;
#print STDERR "called registerVote()\n";
# check parameters
my $query = TWiki::Func::getCgiQuery();
return unless $id eq $query->param('register_vote');
my $user = TWiki::Func::getWikiUserName();
my $isSecret = $query->param('isSecret') || 0;
my $isOpen = $query->param('isOpen') || 0;
my $ident = getIdent($isSecret, $isOpen);
# $ident = int(rand(100))
# if $debug; # for testing
# Apply a weighting for the voting user
my $weightsTopic = TWiki::Func::getPreferencesValue(
'VOTEPLUGIN_WEIGHTINGS');
my $weight = 1;
if ($weightsTopic) {
my ($wweb, $wtopic) = TWiki::Func::normalizeWebTopicName(
$web, $weightsTopic);
if (TWiki::Func::topicExists($wweb, $wtopic)) {
my ($meta, $text) = TWiki::Func::readTopic($wweb, $wtopic);
foreach my $line (split(/\n/, $text)) {
if ($line =~ /^\|\s*(\S+)\s*\|\s*(\d+)\s*\|$/) {
($wweb, $wtopic) = TWiki::Func::normalizeWebTopicName(
undef, $1);
if ($user eq "$wweb.$wtopic") {
$weight = $2 / 100.0;
}
}
}
}
}
# write the votes
my $voteData = "|$id|$ident|$weight|";
my @v;
sub encode {
return sprintf "\\%02x", ord($_[0]);
}
foreach my $key ($query->param()) {
my $val = $query->param($key);
next unless $key =~ s/^voteplugin_//;
$val =~ s/([,=|\\\n\r])/encode($1)/eg;
push @v, "$key=$val";
}
$voteData .= join(',', @v) . "|\n";
saveVotesData($web, $topic, $id, $query->param('isGlobal') || 0,
$query->param('saveTo') || '', $voteData);
# invalidate cache entry
if (defined &TWiki::Cache::invalidateEntry) {
TWiki::Cache::invalidateEntry($web, $topic);
}
}
sub saveVotesData {
my ($web, $topic, $id, $isGlobal, $saveto, $voteData) = @_;
if ($saveto) {
my $text = '';
$saveto =~ /(.*)/;
my ($vw, $vt) = TWiki::Func::normalizeWebTopicName($web, $1);
if (TWiki::Func::topicExists($vw, $vt)) {
$text = TWiki::Func::readTopicText( $vw, $vt );
}
$text .= $voteData;
TWiki::Func::saveTopicText($vw, $vt, $text, 1, 1);
} else {
my $votesFile = getVotesFile($web, $topic, $id, $isGlobal);
# open and lock the votes
open(VOTES, ">>$votesFile") || die "cannot append $votesFile";
flock(VOTES, LOCK_EX); # wait for exclusive rights
seek(VOTES, 0, 2); # seek EOF in case someone else appended
# stuff while we were waiting
print VOTES $voteData;
# unlock and close
flock(VOTES, LOCK_UN);
close VOTES;
}
}
sub getVoteData {
my ($web, $topic, $id, $isGlobal, $saveto) = @_;
my $lines = '';
if ($saveto) {
my ($vw, $vt) = TWiki::Func::normalizeWebTopicName($web, $saveto);
if (TWiki::Func::topicExists($vw, $vt)) {
my $meta;
( $meta, $lines ) = TWiki::Func::readTopic( $vw, $vt );
}
} else {
my $votesFile = getVotesFile($web, $topic, $id, $isGlobal);
if (open(F, "<$votesFile")) {
local $/ = undef;
$lines = ;
close(F);
}
}
return $lines;
}
###############################################################################
sub getVotesFile {
my ($web, $topic, $id, $global) = @_;
my $path = TWiki::Func::getWorkArea('VotePlugin');
my $votesFile = $path.'/'.
($global ? '' : "${web}_${topic}_").
($id ? "_$id" : '');
$votesFile = normalizeFileName($votesFile);
if (! -e $votesFile) {
my $attachPath = TWiki::Func::getPubDir()."/$web/$topic";
my $oldVotesFile = "$attachPath/_Votes" . ($id?"_$id":"") . ".txt";
if (!-e $oldVotesFile ) {
$oldVotesFile = "$attachPath/Votes" . ($id?"_$id":"") . ".txt";
}
if (open(F, "<$oldVotesFile") && open(G, ">$votesFile")) {
local $/;
print G ;
close(G); close(F);
unlink $oldVotesFile;
}
}
return $votesFile;
}
###############################################################################
# wrapper
sub normalizeFileName {
my $fileName = shift;
if (defined &TWiki::Sandbox::normalizeFileName) {
return TWiki::Sandbox::normalizeFileName($fileName);
}
if (defined &TWiki::normalizeFileName) {
return TWiki::normalizeFileName($fileName)
}
TWiki::Func::writeWarning("normalizeFileName not found ... you live dangerous");
return $fileName;
}
###############################################################################
sub getLocaldate {
my( $sec, $min, $hour, $mday, $mon, $year) = localtime(time());
$year = sprintf("%.4u", $year + 1900); # Y2K fix
my $date = sprintf("%.2u-%.2u-%.2u", $year, $mon, $mday);
return $date;
}
###############################################################################
sub inlineError {
return 'Error: '.$_[0].'';
}
###############################################################################
sub expandFormattingTokens {
my $text = shift;
$text =~ s/\$quote/\'/go;# Compatibility
return $text;
if( defined( &TWiki::Func::decodeFormatTokens )) {
$text = TWiki::Func::decodeFormatTokens( $text );
} else {
$text =~ s/\$n\(\)/\n/gs;
$text =~ s/\$n\b/\n$1/gs;
$text =~ s/\$nop(\(\))?//gs;
$text =~ s/\$quot(\(\))?/\"/gs;
$text =~ s/\$percnt(\(\))?/\%/gs;
$text =~ s/\$dollar(\(\))?/\$/gs;
}
$text =~ s/\$doublequote?/\"/gs;
return $text;
}
###############################################################################
sub getIdent {
my ($id, $isSecret, $isOpen) = @_;
my $user = TWiki::Func::getWikiUserName();
my $ident;
if ($isOpen) {
my $date = getLocaldate();
$ident = "$ENV{REMOTE_ADDR},$user,$date";
} else {
$ident = $user;
}
if ($isSecret) {
return md5_base64($ident);
} else {
return $ident;
}
}
###############################################################################
sub showText {
my ($prompt, $vote, $txt) = @_;
my $key = $prompt->{name};
my $desc = $prompt->{desc};
my $row = $prompt->{format};
$row =~ s/\$key/$key/g;
$row =~ s/\$desc/$desc/g;
$row =~ s/\$prompt/$txt/g;
$row =~ s/\$vote/$vote/g;
return $row;
}
###############################################################################
sub showSelect {
my ($id, $prompt, $vote, $select, $keyValueFreq, $totalVotes, $params) = @_;
my $key = $prompt->{name};
my $desc = $prompt->{desc};
my $totty = $totalVotes || 0;
my $row = $prompt->{format};
$row =~ s/\$key/$key/g;
$row =~ s/\$desc/$desc/g;
$row =~ s/\$prompt/$select/g;
$row =~ s/\$vote/$vote/g;
$row =~ s/\$sum/$totty/;
my $bars = '';
foreach my $value (sort {$keyValueFreq->{$b} <=>
$keyValueFreq->{$a}}
keys %{$keyValueFreq}) {
my $score = $keyValueFreq->{$value} || 0;
my $perc = $totty ? int(1000 * $score / $totty) / 10 : 0;
my $bar = expandFormattingTokens($prompt->{chart});
$bar =~ s/\$option/$value/;
$bar =~ s/\$perc/$perc/g;
$bar =~ s/\$score/$score/g;
$bar =~ s/\$bar(\((\d+)\))?/_makeBar($2, $perc, $params)/ge;
$bars .= $bar;
}
$row =~ s/\$bars/$bars/g;
return $row;
}
sub _makeBar {
my ($width, $perc, $params) = @_;
$width = $width || $params->{width} || 300;
my $graph = CGI::img(
{ src=>$pubUrlPath.'/leftbar.gif',
alt=>'leftbar',
height=>14});
$graph .= CGI::img(
{ src => $pubUrlPath.'/mainbar.gif',
alt => 'mainbar',
height => 14,
width => $width / 100 * $perc });
$graph .= CGI::img(
{ src=>$pubUrlPath.'/rightbar.gif',
alt => 'rightbar',
#width => $width - $width / 100 * $perc,
height => 14});
return $graph;
}
###############################################################################
sub showLineOfStars {
my ($form, $prompt, $submit, $needSubmit, $act,
$mean, $myLast, $total) = @_;
my $key = $prompt->{name};
my $desc = $prompt->{desc};
my $max = $prompt->{width};
my $perc = $total ? int(1000 * $mean / $total) / 10 : 0;
$mean = sprintf("%.3g", $mean);
my $row = expandFormattingTokens($prompt->{format});
$row =~ s/\$key/$key/g;
$row =~ s/\$desc/$desc/g;
$row =~ s/\$sum/$total/g;
$row =~ s/\$score/$mean/g;
$row =~ s/\$perc/$perc/g;
$row =~ s/\$mylast/$myLast/g;
my $size = ($row =~ /\$small/) ? 10 : 25;
my $style = $size < 25 ? ' small-star' : '';
my $lis = CGI::li(
{
class=>'current-rating',
style=>'width:'.($size * $mean).'px',
}, CGI::input(
{
type => 'hidden',
name => 'voteplugin_'.$prompt->{name},
id => $form.'_'.$prompt->{name},
value => '0',
}));
if ($needSubmit) {
$lis .= CGI::li(
{
class=>'my-rating',
id => $prompt->{name}.'_rated',
style=>'width:0px; z-index:2',
}, ' ');
}
if ($submit) {
foreach my $i (1..$max) {
$lis .= CGI::li(
CGI::a(
{
href=>"javascript:VotePlugin_clicked('$form',".
"'$prompt->{name}', $i, ".
($needSubmit ? 'false' : 'true').", $size)",
style=>'width:'.($size * $i).
'px;z-index:'.($max - $i + 2),
}, $i));
}
}
my $ul = CGI::ul(
{
class=>'star-rating'.$style,
style=>'width:'.($max * $size).'px',
}, $lis);
$row =~ s/\$(small|large)/$ul/g;
return $row;
}
sub isTrue {
my( $value, $default ) = @_;
$default ||= 0;
return $default unless defined( $value );
$value =~ s/^\s*(.*?)\s*$/$1/gi;
$value =~ s/off//gi;
$value =~ s/no//gi;
$value =~ s/false//gi;
return ( $value ) ? 1 : 0;
}
1;