# Plugin for TWiki Enterprise Collaboration Platform, http://TWiki.org/ # # Copyright (C) 2016-2018 Peter Thoeny, peter[at]thoeny.org # Copyright (C) 2016-2018 TWiki Contributors. All Rights Reserved. # # 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 3 # of the License, or (at your option) any later version. For # more details read LICENSE in the root of this distribution. # # 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 # # As per the GPL, removal of this notice is prohibited. package TWiki::Plugins::TWikiSheetPlugin::Core; use Config; use Fcntl qw(:flock); our $debug = $TWiki::cfg{Plugins}{TWikiSheetPlugin}{Debug} || 0; # ========================= # constants our $canFlock = $Config{d_flock}; # file locking is not available on all platforms our $vesc = "\x06"; our $vbar = "\x07"; our $tmlToJson = { '\\%\\x06VBAR\\%' => $vbar, '|' => $vbar, '\\%\\x06BR\\%' => "\\n", '
' => "\\n", '
' => "\\n", '
' => "\\n", }; our $jsonToTml = { '\\|' => '%VBAR%', "\\n" => '%BR%', }; our $tableMarker = "~TWIKISHEETPLUGIN_TABLE_MARKER~"; our $pubBase = '%PUBURLPATH%/%SYSTEMWEB%/TWikiSheetPlugin'; our $restGetUrl = '%SCRIPTURL{rest}%/TWikiSheetPlugin/get'; our $restSaveUrl = '%SCRIPTURL{rest}%/TWikiSheetPlugin/save'; our $c = $debug ? '' : '//'; our $htmlHead = <<"HERE"; HERE our $htmlModeEdit = <<"HERE";
HERE our $htmlModeToggle = <<"HERE";
HERE our $htmlModeClassic = <<"HERE";
\$htmlTable
HERE our $htmlJavaScript = <<"HERE"; HERE # ========================= sub new { my ( $class, $baseWebTopic ) = @_; my $concurrentEdit = $TWiki::cfg{Plugins}{TWikiSheetPlugin}{ConcurrentEdit} || 0; my $concurrentEditRefresh = $TWiki::cfg{Plugins}{TWikiSheetPlugin}{ConcurrentEditRefresh} || 10; my $this = { mode => $TWiki::cfg{Plugins}{TWikiSheetPlugin}{Mode} || 'toggle', concurrentEdit => int($concurrentEdit), concurrentEditRefresh => int($concurrentEditRefresh) * 1000, workDir => TWiki::Func::getWorkArea( 'TWikiSheetPlugin' ), tableCount => {} }; bless( $this, $class ); _writeDebug( "new() - constructor" ); my $authenticated = TWiki::Func::getContext()->{authenticated} ? 1 : 0; my $html = $htmlHead; $html =~ s/\$authenticated/$authenticated/g; $html =~ s/\$concurrentEditRefresh/$this->{concurrentEditRefresh}/go; TWiki::Func::addToHEAD( 'TWIKISHEETPLUGIN', $html ); return $this; } # ========================= sub protectVariables { # do not uncomment, use $_[0], $_[1]... instead ### my ( $text, $topic, $web, $meta ) = @_; my $this = shift; my $webTopic = "$_[2].$_[1]"; _writeDebug( "protectVariables( $webTopic )"); $_[0] =~ s/(\%TWIKISHEET\{.*?}\%)(([\n\r]+ *\|[^\n\r]+)*)/$this->_protectVariablesInTable( $1, $2 )/ges; } # ========================= sub processText { # do not uncomment, use $_[0], $_[1]... instead ### my ( $text, $topic, $web, $included, $meta ) = @_; my $this = shift; my $webTopic = "$_[2].$_[1]"; _writeDebug( "processText( $webTopic )" ); $_[0] =~ s/\%TWIKISHEET\{(.*?)}\%(([\n\r]+ *\|[^\n\r]+)*)/$this->_processTable( $1, $2, $webTopic )/ges; } # ========================= sub restGetTable { my( $this, $query ) = @_; require JSON; my $action = $query->param( 'action' ); my $webTopic = $query->param( 'webTopic' ); my $tn = $query->param( 'tableNumber' ); my $index = $query->param( 'index' ) || 0; _writeDebug( "restGetTable( $action, $webTopic, table: $tn, index: $index )" ); if( $action eq 'getReadIndex' ) { $this->_loadTableChanges( $webTopic, $tn, \$index ); return '{ "error": 0, "index": ' . $index . ' }'; } elsif( $action eq 'getChanges' ) { my @changes = $this->_loadTableChanges( $webTopic, $tn, \$index ); return '{ "error": 0, "changes": [' . join( ', ', @changes ) . '], "index": ' . $index . ' }'; } elsif( $action eq 'getRenderedTable' ) { my ( $html, $error ) = $this->_getRenderedTable( $webTopic, $tn ); return JSON::encode_json( { error => $error, html => $html } ); } my $result = '{ error: 1, message: "Unsupported action $action" }'; return $result; } # ========================= sub restSaveTable { my( $this, $query ) = @_; require JSON; my $action = $query->param( 'action' ); my $webTopic = $query->param( 'webTopic' ); my $tn = $query->param( 'tableNumber' ); my $changes = JSON::decode_json( $query->param( 'changes' ) ); _writeDebug( "restSaveTable( $action, $webTopic, table: $tn, changes: ".$query->param( 'changes' )." )" ); if( $action ne 'update' ) { my $message = "Nothing to do"; return '{ "error": 0, "message": "' . $message . '" }'; } my $wikiName = TWiki::Func::getWikiName(); unless( $wikiName ) { my $message = "Error: You must be logged in to update content"; return '{ "error": 2, "message": "' . $message . '" }'; } my( $web, $topic ) = TWiki::Func::normalizeWebTopicName( '', $webTopic ); my $lockFile = $this->_getFileName( $webTopic, 'update', 'lock' ); my $lockHandle = undef; if( $canFlock ) { open( $lockHandle, ">$lockFile") || die "Cannot create lock $lockFile - $!\n"; flock( $lockHandle, LOCK_EX ); # wait for exclusive rights } my( $topicMeta, $topicText ) = TWiki::Func::readTopic( $web, $topic ); unless( TWiki::Func::checkAccessPermission('CHANGE', $wikiName, $topicText, $topic, $web, $topicMeta ) ) { my $message = "Error: You do not have permission to update topic $topic"; return '{ "error": 3, "message": "' . $message . '" }'; } my $n = 0; my $changed = 0; $topicText =~ s/(\%TWIKISHEET\{.*?}\%)(([\n\r]+ *\|[^\n\r]+)*)/$this->_updateTable( $1, $2, $webTopic, $tn, $n++, $changes, \$changed )/ges; #_writeDebug( "=========\n$topicText\n========= ($changed)" ); if( $changed ) { TWiki::Func::saveTopic( $web, $topic, $topicMeta, $topicText ); } if( $canFlock ) { flock( $lockHandle, LOCK_UN ); close( $lockHandle ); } my $message = "Table update OK"; unless( $changed ) { $message = "Table update ignored"; } my $result = '{ "error": 0, "message": "' . $message . '" }'; return $result; } # ========================= sub _protectVariablesInTable { my( $this, $var, $table ) = @_; my %params = TWiki::Func::extractParameters( $args ); $table =~ s/(\%)([A-Z])/$1$vesc$2/gs; return "$var$table"; } # ========================= sub _processTable { my( $this, $args, $table, $webTopic ) = @_; my %params = TWiki::Func::extractParameters( $args ); my $save = TWiki::Func::isTrue( $params{save}, 1 ); my $mode = $params{mode} || $this->{mode}; my $concurrent = TWiki::Func::isTrue( $params{concurrent}, $this->{concurrentEdit} ); my $addWebTopic = ''; unless( defined $this->{tableCount}{$webTopic} ) { $this->{tableCount}{$webTopic} = -1; $addWebTopic = "\n twSheets['$webTopic'] = [];"; } my $n = $this->{tableCount}{$webTopic} + 1; $this->{tableCount}{$webTopic} = $n; my $toggleJS = ''; my $toggleView = ' style="display: none;"'; my $toggleEdit = ''; if( $mode =~ /^toggle/ && $mode !~ /^toggle-(on|edit)$/ ) { $toggleJS = "\n twSheetSetReadOnly( '$webTopic', $n, 1 );"; $toggleView = ''; $toggleEdit = ' style="display: none;"'; } $mode =~ s/\-.*//; _writeDebug( "_prepareTable( $webTopic, table $n, save $save, mode $mode )" ); # collect Handsontable options my $sheetOptions = 'formulas: true,'; foreach my $k ( keys %params ) { next if $k =~ /^(_.*|mode|save)$/; my $v = $params{$k}; $sheetOptions .= "\n $k: $v,"; } # extract table cells and create JavaScript table object my @tableRows = $this->_parseTable( $table ); my $tableObject = '['; foreach my $cellsRef ( @tableRows ) { $tableObject .= ' [' . join( ', ', @$cellsRef ) . "],\n"; } $tableObject =~ s/,$//s; $tableObject .= ' ]'; _writeDebug( "table data: $tableObject" ); # create HTML with div for Handsontable my $twSheetID = "twSheetContainer$webTopic$n"; $twSheetID =~ s/[^a-zA-Z0-9]//g; my $text = ''; if( $mode eq 'edit' ) { $text = $htmlModeEdit; } elsif( $mode eq 'toggle' ) { $text = $htmlModeToggle; } else { # 'classic' mode $table =~ s/^[\n\r]+//s; $table =~ s/$vesc//gs; $text = $htmlModeClassic; $text =~ s/\$htmlTable/$table/go; } $text =~ s/\$toggleView/$toggleView/go; $text =~ s/\$toggleEdit/$toggleEdit/go; # add JavaScript with Handsontable my $js = $htmlJavaScript; $js =~ s/\$addWebTopic/$addWebTopic/go; $js =~ s/\$webTopic/$webTopic/go; $js =~ s/\$save/$save/go; $js =~ s/\$mode/$mode/go; $js =~ s/\$concurrent/$concurrent/go; $js =~ s/\$sheetOptions/$sheetOptions/go; $js =~ s/\$tableObject/$tableObject/go; $js =~ s/\$thisTwSheet/twSheets['$webTopic'][$n]/go; $js =~ s/\$toggleJS/$toggleJS/go; $text .= $js; $text =~ s/\$twSheetID/$twSheetID/go; $text =~ s/\$webTopic/$webTopic/go; $text =~ s/\$n/$n/go; return $text; } # ========================= sub _updateTable { my( $this, $twikiSheetVar, $tableText, $webTopic, $tableNr, $n, $changesSet, $changedRef ) = @_; # - $twikiSheetVar: TWIKISHEET variable with parameters, such as "%TWIKISHEET{ save="0" }%". # - $tableText: TWiki table text that follows the TWIKISHEET variable. # - $tableNr: Desired TWIKISHEET table number on topic. # - $n: Actual TWIKISHEET table number on topic; action is only taken if $n == $tableNr. # - $changesSet: Reference to array of changes. Each change is an array where the first element indicates # the unique ID of a sheet (composed of the WikiName of the user, a dash, and a random 4 digit number), # followeed by the type of change, followed by elements that depend on the type of change. Example # $changesSet with explanation: # [ [ "JaneSmith-1234", "change", [4,5,"old","x"], [4,6,"","y"]], # cell changes, row 4, column 5 & 6, new values "x" and "y" # [ "JaneSmith-1234", "createCol", 6, 1], # insert a new column at column 6 # [ "JaneSmith-1234", "removeCol", 6, 2], # delete columns 6 and 7 # [ "JaneSmith-1234", "createRow", 3, 1], # insert a new row at row 3 # [ "JaneSmith-1234", "removeRow", 3, 2] # delete rows 4 and 5 # ] # - $changedRef: Reference to changed flag; set to 1 if TWiki Sheet has changes, e.g. topic needs to be saved unless( $tableNr == $n ) { return "$twikiSheetVar$tableText"; } if( $twikiSheetVar =~ /\bsave=["'](0|off|no|false)["']/ ) { # save="0" flag, so don't save _writeDebug( "_updateTable( $twikiSheetVar, $tableNr, $n ) - save cancelled due to save=\"0\" parameter" ); return "$twikiSheetVar$tableText"; } _writeDebug( "_updateTable( $twikiSheetVar, $tableNr, $n )" ); my $needsSave = 0; my @tableRows = $this->_parseTable( $tableText, 1 ); my @saveChanges = (); foreach my $changes ( @$changesSet ) { my $user = shift( @$changes ); my $action = shift( @$changes ); if( $action eq 'change' ) { foreach my $ch ( @$changes ) { my $row = $$ch[0]; my $col = $$ch[1]; my $old = $$ch[2] || ''; my $new = $$ch[3]; _writeDebug( " - save action $action, row $row, col $col, \"$new\"" ); if( $tableRows[$row][$col] ne $new ) { $tableRows[$row][$col] = $new; $needsSave = 1; } push( @saveChanges, JSON::encode_json( [ $user, $action, int($row), int($col), $old, $new ] ) ); } } elsif( $action eq 'createRow' ) { $needsSave = 1; my $row = $$changes[0]; my $n = $$changes[1]; _writeDebug( " - save action $action, row $row, n $n" ); my $nCol = scalar @{$tableRows[0]}; my @emptyCells = (); for( my $i = 0; $i < $nCol; $i++ ) { push( @emptyCells, '' ); } for( my $i = 0; $i < $n; $i++ ) { splice( @tableRows, $row, 0, \@emptyCells ); } push( @saveChanges, JSON::encode_json( [ $user, $action, int($row), int($n) ] ) ); } elsif( $action eq 'removeRow' ) { $needsSave = 1; my $row = $$changes[0]; my $n = $$changes[1]; _writeDebug( " - save action $action, row $row, n $n" ); splice( @tableRows, $row, $n ); push( @saveChanges, JSON::encode_json( [ $user, $action, int($row), int($n) ] ) ); } elsif( $action eq 'createCol' ) { $needsSave = 1; my $col = $$changes[0]; my $n = $$changes[1]; my $nRow = scalar @tableRows; _writeDebug( " - save action $action, col $col, n $n" ); for( my $r = 0; $r < $nRow; $r++ ) { for( my $c = $col; $c < $col + $n; $c++ ) { splice( @{$tableRows[$r]}, $c, 0, '' ); } } push( @saveChanges, JSON::encode_json( [ $user, $action, int($col), int($n) ] ) ); } elsif( $action eq 'removeCol' ) { $needsSave = 1; my $col = $$changes[0]; my $n = $$changes[1]; my $nRow = scalar @tableRows; _writeDebug( " - save action $action, col $col, n $n" ); for( my $r = 0; $r < $nRow; $r++ ) { splice( @{$tableRows[$r]}, $col, $n ); } push( @saveChanges, JSON::encode_json( [ $user, $action, int($col), int($n) ] ) ); } } $this->_saveTableChanges( $webTopic, $tableNr, \@saveChanges ); if( $needsSave ) { $$changedRef = 1; _writeDebug( " - old table:\n===== OLD =====$tableText\n===============" ); my $text = ''; foreach my $row (@tableRows) { $text .= "\n|"; foreach my $cell (@{$row}) { $cell = '' unless( defined $cell ); foreach my $key ( keys %$jsonToTml ) { $cell =~ s/$key/$jsonToTml->{$key}/gs; } $text .= " $cell |"; } } _writeDebug( " - new table:\n===== NEW =====$text\n===============" ); return "$twikiSheetVar$text"; } else { _writeDebug( " - no change in table, save not needed" ); return "$twikiSheetVar$tableText"; } } # ========================= sub _parseTable { my( $this, $text, $noJson ) = @_; # extract table cells $text =~ s/^[\n\r]+//s; my $maxCells = 0; my @tableRows = (); foreach my $line ( split( /[\n\r]+/, $text ) ) { next unless $line; $line =~ s/^ *\|//; $line =~ s/\| *$//; foreach my $key ( keys %$tmlToJson ) { $line =~ s/$key/$tmlToJson->{$key}/gs; } unless( $noJson ) { $line =~ s/$vesc//g; $line =~ s/"/\\"/g; } my @cells = map { s/^ *//; s/ *$//; s/$vbar/\|/g; unless( $noJson || /^[0-9]+$/ ) { $_ = "\"$_\"" } $_ } split(/\|/, $line); if( scalar @cells > $maxCells ) { $maxCells = scalar @cells; } push( @tableRows, \@cells ); } foreach my $cellsRef ( @tableRows ) { # make sure all rows have the same number of cells my $empty = $noJson ? '' : '""'; while( scalar @$cellsRef < $maxCells ) { push( @$cellsRef, $empty ); } } return @tableRows; } # ========================= sub _getRenderedTable { my ( $this, $webTopic, $tableNr ) = @_; my( $web, $topic ) = TWiki::Func::normalizeWebTopicName( '', $webTopic ); my( $meta, $text ) = TWiki::Func::readTopic( $web, $topic ); my $wikiName = TWiki::Func::getWikiName(); unless( TWiki::Func::checkAccessPermission('VIEW', $wikiName, $text, $topic, $web, $tmeta ) ) { my $message = "Error: You do not have permission to view topic $topic"; return( '', $message ); } my $n = 0; $text =~ s/(\%TWIKISHEET\{.*?}\%)(([\n\r]+ *\|[^\n\r]+)*)/$this->_markTable( $1, $2, $webTopic, $tableNr, $n++ )/ges; $text = TWiki::Func::expandCommonVariables( $text, $topic, $web, $meta ); my $html = TWiki::Func::renderText( $text, $web ); $html =~ s/^.*?$tableMarker//s; $html =~ s/$tableMarker.*$//s; return( $html, '' ); } # ========================= sub _markTable { my( $this, $twikiSheetVar, $tableText, $webTopic, $tableNr, $n ) = @_; unless( $tableNr == $n ) { return "$tableText"; } return "$tableMarker$tableText\n$tableMarker"; } # ========================= sub _loadTableChanges { my ( $this, $webTopic, $tableNr, $indexRef ) = @_; my $changesFile = $this->_getFileName( $webTopic, $tableNr, 'txt' ); my $lockFile = $this->_getFileName( $webTopic, $tableNr, 'lock' ); my $lockHandle = undef; if( $canFlock ) { open( $lockHandle, ">$lockFile") || die "Cannot create lock $lockFile - $!\n"; flock( $lockHandle, LOCK_SH ); # wait for shared rights } my $text = ''; if( open( FILE, "<$changesFile" ) ) { local $/ = undef; # set to read to EOF $text = ; close( FILE ); } $text = '' unless $text; # no undefined $text =~ /^(.*)$/gs; # untaint, it's safe $text = $1; if( $canFlock ) { flock( $lockHandle, LOCK_UN ); close( $lockHandle ); } my @changes = split( /[\n\r]/, $text ); my $size = scalar @changes; if( $$indexRef ) { splice( @changes, 0, $$indexRef ); } $$indexRef = $size; _writeDebug( "_loadTableChanges( $webTopic, table $tableNr, last line " . $changes[$#changes] . " )" ); return @changes; } # ========================= sub _saveTableChanges { my ( $this, $webTopic, $tableNr, $changes ) = @_; _writeDebug( "_saveTableChanges( $webTopic, table $tableNr, last line " . $$changes[$#$changes] . " )" ); return unless scalar @$changes; my $changesFile = $this->_getFileName( $webTopic, $tableNr, 'txt' ); my $lockFile = $this->_getFileName( $webTopic, $tableNr, 'lock' ); my $lockHandle = undef; if( $canFlock ) { open( $lockHandle, ">$lockFile") || die "Cannot create lock $lockFile - $!\n"; flock( $lockHandle, LOCK_EX ); # wait for exclusive rights } unless( open( FILE, ">>$changesFile" ) ) { if( $canFlock ) { flock( $lockHandle, LOCK_UN ); close( $lockHandle ); } die "Can't create file $changesFile - $!\n"; } print FILE join("\n", @$changes ) . "\n"; close( FILE); if( $canFlock ) { flock( $lockHandle, LOCK_UN ); close( $lockHandle ); } } # ========================= sub _getFileName { my ( $this, $webTopic, $tableNr, $ext ) = @_; $webTopic =~ s/[^a-zA-Z0-9\-\.]/_/go; $webTopic =~ s/_+/_/go; my $name = $this->{workDir} . '/tws.' . $webTopic . '_' . $tableNr . '.' . $ext; $name =~ /^(.*)$/gs; # untaint, it's safe $name = $1; return $name; } # ========================= sub _writeDebug { my ( $msg ) = @_; return unless( $debug ); TWiki::Func::writeDebug( "- TWikiSheetPlugin::Core::$msg" ); } # ========================= sub _writeError { my ( $msg ) = @_; print STDERR "ERROR TWiki TWikiSheetPlugin::Core::$msg\n"; } # ========================= 1;