1<?php
2/**
3 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
4 * @author     Stephan Dekker <Stephan@SparklingSoftware.com.au>
5 */
6
7if(!defined('DOKU_INC')) die();
8if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN',DOKU_INC.'lib/plugins/');
9require_once(DOKU_PLUGIN.'git/lib/Git.php');
10require_once(DOKU_INC.'inc/search.php');
11require_once(DOKU_INC.'/inc/DifferenceEngine.php');
12
13function git_callback_search_wanted(&$data,$base,$file,$type,$lvl,$opts) {
14    global $conf;
15
16	if($type == 'd'){
17		return true; // recurse all directories, but we don't store namespaces
18	}
19
20    if(!preg_match("/.*\.txt$/", $file)) {  // Ignore everything but TXT
21		return true;
22	}
23
24	// get id of this file
25	$id = pathID($file);
26
27    $item = &$data["$id"];
28    if(! isset($item)) {
29        $data["$id"]= array('id' => $id,
30                'file' => $file);
31    }
32}
33
34
35class helper_plugin_git extends DokuWiki_Plugin {
36
37    var $dt = null;
38    var $sqlite = null;
39
40    function getMethods(){
41        $result = array();
42        $result[] = array(
43          'name'   => 'cloneRepo',
44          'desc'   => 'Creates a new clone of a repository',
45          'params' => array(
46            'destination' => 'string'),
47          'return' => array('result' => 'array'),
48        );
49
50        // and more supported methods...
51        return $result;
52    }
53
54    function rebuild_data_plugin_data() {
55        // Load the data plugin only if we need to
56        if(!$this->dt)
57        {
58            $this->dt =& plugin_load('syntax', 'data_entry');
59            if(!$this->dt)
60            {
61                msg('Error loading the data table class from GIT Helper. Make sure the data plugin is installed.',-1);
62                return;
63            }
64        }
65
66        global $conf;
67        $result = '';
68        $data = array();
69        search($data,$conf['datadir'],'git_callback_search_wanted',array('ns' => $ns));
70
71        $output = array();
72        foreach($data as $entry) {
73
74            // Get the content of the file
75            $filename = $conf['datadir'].$entry['file'];
76            if (strpos($filename, 'syntax') > 0) continue;  // Skip instructional pages
77            $body = @file_get_contents($filename);
78
79            // Run the regular expression to get the dataentry section
80            $pattern = '/----.*dataentry.*\R----/s';
81            if (preg_match($pattern, $body, $matches) === false) {
82                continue;
83            }
84
85            foreach ($matches as $match) {
86
87                // Re-use the handle method to get the formatted data
88                $cleanedMatch = htmlspecialchars($match);
89                $dummy = "";
90                $formatted = $this->dt->handle($cleanedMatch, null, null, $dummy);
91                $output['id'.count($output)] = $formatted;
92
93                // Re-use the save_data method to .... (drum roll) save the data.
94                // Ignore the returned html, just move on to the next file
95                $html = $this->dt->_saveData($formatted, $entry['id'], 'Title'.count($output));
96            }
97        }
98
99        msg('Data entry plugin found and refreshed all '.count($output).' entries.');
100    }
101
102    /**
103     * Resets a GIT cache by setting the timestamp to ZERO (1st of jan 1970)
104     *
105     * @param   string  repository name. Either: 'Local' or 'upstream'
106     */
107    function resetGitStatusCache($repo)
108    {
109        $res = $this->loadSqlite();
110        if (!$res)
111        {
112            msg('Error loading sqlite');
113            return;
114        }
115
116        // Set the time to zero, so the first alert msg will set the correct status
117        $sql = "INSERT OR REPLACE INTO git (repo, timestamp, status ) VALUES ('".$repo."', 0, 'clean');";
118        $this->sqlite->query($sql);
119    }
120
121    function haveChangesBeenSubmitted()
122    {
123        $changesAwaiting = true;
124
125        $res = $this->loadSqlite();
126        if (!$res) return;
127
128        $res = $this->sqlite->query("SELECT status FROM git WHERE repo = 'local'");
129        $rows = $this->sqlite->res2arr($res);
130        $status = $rows[0]['status'];
131        if ($status !== 'submitted' ) $changesAwaiting = false;
132
133        return $changesAwaiting;
134    }
135
136    function submittChangesForApproval()
137    {
138        $res = $this->loadSqlite();
139        if (!$res) return;
140
141        // Set the time to zero, so the first alert msg will set the correct status
142        $hundred_years_into_future = time() + (60 * 60 * 24 * 365 * 100);
143        $sql = "INSERT OR REPLACE INTO git (repo, timestamp, status ) VALUES ('local', ".$hundred_years_into_future.", 'submitted');";
144        $this->sqlite->query($sql);
145
146        $this->changeReadOnly(true);
147        $this->sendNotificationEMail();
148    }
149
150    function sendNotificationEMail()
151    {
152        global $conf;
153        $this->getConf('');
154
155        $notify = $conf['plugin']['git']['commit_notifcations'];
156        $local_status_page = wl($conf['plugin']['git']['local_status_page'],'',true);
157
158        $mail = new Mailer();
159        $mail->to($notify);
160        $mail->subject('An improvement has been submitted for approval!');
161        $mail->setBody('Please review the proposed changes before the next meeting: '.$local_status_page);
162
163        return $mail->send();
164    }
165
166
167    function cloneRepo($origin, $destination) {
168        global $conf;
169        $this->getConf('');
170        $git_exe_path = $conf['plugin']['git']['git_exe_path'];
171
172        try
173        {
174            $repo = new GitRepo($destination, true, false);
175            $repo->git_path = $git_exe_path;
176            $repo->clone_from($origin);
177
178
179        }
180        catch (Exception $e)
181        {
182            msg($e->getMessage());
183        }
184    }
185
186    function changeReadOnly($readonly = true)
187    {
188        global $config_cascade;
189
190        $AUTH_ACL = file($config_cascade['acl']['default']);
191
192        $lines = array();
193        foreach($AUTH_ACL as $line){
194            if(strpos(strtolower($line), strtolower('@USER')) === FALSE)
195            {
196                $lines[] = $line;
197                continue;
198            }
199
200            if ($readonly)
201            {
202                $lines[] = '*               @user         '.AUTH_READ;
203            }
204            else
205            {
206                $lines[] = '*               @user         '.AUTH_DELETE;
207            }
208
209            $lines[] = $replaced;
210        }
211
212        // save it
213        io_saveFile($config_cascade['acl']['default'], join('',$lines));
214    }
215
216    function render_commit_selector($renderer, $commits)
217    {
218        // When viewing file content differences, the hash gets set in the html request, so we can select the correct option
219        $selected_hash = trim($_REQUEST['hash']);
220        if ($selected_hash === '') $selected_hash = 'all'; // By default select "all".
221
222        $renderer->doc .= "<select id='git_commit' width=\"800\" style=\"width: 800px\" onchange='ChangeGitCommit();'>";
223        $index = 1;
224        $renderer->doc .= "<option>Select a commit</option>";
225        foreach($commits as $commit)
226        {
227            // Replace merge commit message with a more user friendly msg, leaving the orrigional
228            $raw_message = $commit['message'];
229            $pos = strpos(strtolower($raw_message), 'merge');
230            if ($pos !== false) $msg = 'Merge';
231            else $msg = $raw_message;
232
233            // Create option in DDL
234            $renderer->doc .= "<option value=\"".$commit['hash']."\"";
235            // Is this option already selected before an html round-trip ??
236            if ($commit['hash'] === $selected_hash) $renderer->doc .= "selected=\"selected\"";
237            if ($commit['hash'] === 'all') $renderer->doc .= ">".$msg."</option>";
238            else $renderer->doc .= ">".$index." - ".$msg."</option>";
239            $index++;
240        }
241        $renderer->doc .= '</select>';
242    }
243
244    function render_changed_files_table($renderer, $commits, $repo)
245    {
246        $selected_hash = trim($_REQUEST['hash']);
247        if ($selected_hash === '') $selected_hash = 'all'; // By default select "all".
248
249        foreach($commits as $commit)
250        {
251            $hash = $commit['hash'];
252
253            if($hash === $selected_hash || $hash === 'new') $divVisibility = ""; // Show the selected
254            else $divVisibility = " display:none;"; // Hide the rest
255
256            $renderer->doc .= "<div class=\"commit_div\" id='".$hash."' style=\"".$divVisibility." width: 100%;\">";
257
258            // Commits selected to show changes for
259            if ($hash === 'new')
260            {
261                $files = explode("\n", $repo->get_status());
262            }
263            else if($hash === 'all')
264            {
265                $files = explode("\n", $repo->get_files_by_commit('origin/master..HEAD'));
266            }
267            else
268            {
269                $files = explode("\n", $repo->get_files_by_commit($hash));
270            }
271
272            // No files
273            if ($files === null || count($files) === 1)
274            {
275               $renderer->doc .= "<p><br/>No files have changed for the selected item. If a merge is selected, then no conflicts were detected.</p>";
276            }
277            else
278            {
279                $renderer->doc .= '<br/><h3>The content of the selected commit:</h3>';
280
281                $renderer->doc .= "<table><tr><th>Change type</th><th>Page</th><th>Changes</th></tr>";
282                foreach ($files as $file)
283                {
284                    if ($file === "") continue;
285
286                    $renderer->doc .= "<tr><td>";
287
288                    $change = substr($file, 0, 2);
289                    if (strpos($change, '?') !== false)
290                        $renderer->doc .= "Added:";
291                    else if (strpos($change, 'M') !== false)
292                        $renderer->doc .= "Modified:";
293                    else if (strpos($change, 'A') !== false)
294                        $renderer->doc .= "Added:";
295                    else if (strpos($change, 'D') !== false)
296                        $renderer->doc .= "Removed:";
297                    else if (strpos($change, 'R') !== false)
298                        $renderer->doc .= "Removed:";
299                    else if (strpos($change, 'r') !== false)
300                        $renderer->doc .= "Removed:";
301
302                    $renderer->doc .= "</td><td>";
303                    $file = trim(substr($file, 2));
304                    $page = $this->getPageFromFile($file);
305                    $renderer->doc .=  '<a href="'.DOKU_URL.'doku.php?id='.$page.'">'.$page.'</a>';
306
307                    $renderer->doc .= "</td><td>";
308                    $renderer->doc .= '   <form method="post">';
309                    $renderer->doc .= '      <input type="hidden" name="filename"  value="'.$file.'" />';
310                    $renderer->doc .= '      <input type="hidden" name="hash"  value="'.$commit['hash'].'" />';
311                    $renderer->doc .= '      <input type="submit" value="View Changes" />';
312                    $renderer->doc .= '   </form>';
313                    $renderer->doc .= "</td>";
314                    $renderer->doc .= "</tr>";
315                }
316                $renderer->doc .= "</table>";
317            }
318            $renderer->doc .= "</div>\n";
319
320            // Initially, hide second and further tables
321            $divVisibility = " display:none;";
322        }
323    }
324
325    function getPageFromFile($file)
326    {
327        // If it's not a wiki page, just return the normal filename
328        if (strpos($file, 'pages/') === false) return $file;
329
330        // Replace all sorts of stuff so it makes sense to non-technical users.
331        $page = str_replace('pages/', '', $file);
332        $page = str_replace('.txt', '', $page);
333        $page = str_replace('/', ':', $page);
334        $page = trim($page);
335
336        return $page;
337    }
338
339
340    function renderChangesMade(&$renderer, &$repo, $mode)
341    {
342        global $conf;
343        $this->getConf('');
344
345        $fileForDiff = trim($_REQUEST['filename']);
346        $page = $this->getPageFromFile($fileForDiff);
347        $hash = trim($_REQUEST['hash']);
348        if ($fileForDiff !== '')
349        {
350            $renderer->doc .= '<div id="diff_table" class="table">';
351
352            //Write header
353            $renderer->doc .= '<h2>Changes to: '.$page.'</h2>';
354
355            if ($mode == 'Approve Local') {
356                if ($hash === 'all') $renderer->doc .= '<p>Left = The current page in Live<br/>';
357                else $renderer->doc .= '<p>Left = The page before the selected commited retrieved from GIT <br/>';
358                $renderer->doc .= 'Right = The page after the selected commit</p>';
359
360                // LEFT: Find the file before for the selected commit
361                if ($hash === 'all') $l_text = $repo->getFile($fileForDiff, 'origin/master');
362                else $l_text = $repo->getFile($fileForDiff, $hash."~1");
363
364                // RIGHT: Find the file for the selected commit
365                if ($hash === 'all') $r_text = $repo->getFile($fileForDiff, 'HEAD');
366                else $r_text = $repo->getFile($fileForDiff, $hash);
367            }
368            else if ($mode == 'Commit local') {
369                $renderer->doc .= '<p>Left = The last page commited to GIT <br/>';
370                $renderer->doc .= 'Right = Current wiki content</p>';
371
372                // LEFT: Latest in GIT
373                $l_text = $repo->getFile($fileForDiff, 'HEAD');
374
375                // RIGHT:  Current
376                $current_filename = $conf['savedir'].'/'.$fileForDiff;
377                $current_filename = str_replace("/", "\\", $current_filename);
378                $r_text = $this->getFileContents($current_filename);
379            }
380            else if ($mode == 'Merge upstream') {
381                $renderer->doc .= '<p>Left = Current wiki content<br/>';
382                $renderer->doc .= 'Right = Upstream changes to be merged</p>';
383
384                // LEFT:  Current
385                $current_filename = $conf['savedir'].'/'.$fileForDiff;
386                $current_filename = str_replace("/", "\\", $current_filename);
387                $l_text = $this->getFileContents($current_filename);
388
389                // RIGHT: Latest in GIT to be merged
390                $r_text = $repo->getFile($fileForDiff, $hash);
391            }
392
393            // Show diff
394            $df = new Diff(explode("\n",htmlspecialchars($l_text)), explode("\n",htmlspecialchars($r_text)));
395            $tdf = new TableDiffFormatter();
396            $renderer->doc .= '<table class="diff diff_inline">';
397            $renderer->doc .= $tdf->format($df);
398            $renderer->doc .= '</table>';
399            $renderer->doc .= '</div>';
400        }
401    }
402
403    function renderAdminApproval(&$renderer)
404    {
405        $isAdmin = $this->isCurrentUserAnAdmin();
406        if ($isAdmin)
407        {
408            $renderer->doc .= '<form method="post">';
409            $renderer->doc .= '   <input type="submit" name="cmd[revert]" value="Reject and revert Approval Submission" />';
410            $renderer->doc .= '   <input type="submit" name="cmd[push]" value="Push to live!" />';
411            $renderer->doc .= '</form>';
412        }
413    }
414
415    function isCurrentUserAnAdmin()
416    {
417        function isCurrentUserAnAdmin()
418        {
419            global $INFO;
420            return ($INFO['isadmin']);
421        }
422    }
423
424    function getFileContents($filename)
425    {
426        // get contents of a file into a string
427        $handle = fopen($filename, "r");
428        $contents = fread($handle, filesize($filename));
429        fclose($handle);
430
431        return $contents;
432    }
433
434    function loadSqlite()
435    {
436        if ($this->sqlite) return true;
437
438        $this->sqlite =& plugin_load('helper', 'sqlite');
439        if (is_null($this->sqlite)) {
440            msg('The sqlite plugin could not loaded from the GIT Plugin helper', -1);
441            return false;
442        }
443        if($this->sqlite->init('git',DOKU_PLUGIN.'git/db/')){
444            return true;
445        }else{
446             msg('Submitting changes failed as the GIT cache failed to initialise.', -1);
447             return false;
448        }
449    }
450
451    function hasLocalCacheTimedOut()
452    {
453        $hasCacheTimedOut = false;
454
455        $res = $this->loadSqlite();
456        if (!$res) return;
457
458        $sql = "SELECT timestamp FROM git WHERE repo = 'local'";
459        $res = $this->sqlite->query($sql);
460        $rows = $this->sqlite->res2arr($res);
461        $timestamp = $rows[0]['timestamp'];
462        if ($timestamp < time() - (60 * 30))  // 60 seconds x 5 minutes
463        {
464            $hasCacheTimedOut = true;
465        }
466
467        return $hasCacheTimedOut;
468    }
469
470    function readLocalChangesAwaitingFromCache()
471    {
472        $changesAwaiting = true;
473
474        $res = $this->loadSqlite();
475        if (!$res) return;
476
477        $sql = "SELECT status FROM git WHERE repo = 'local'";
478        $res = $this->sqlite->query($sql);
479        $rows = $this->sqlite->res2arr($res);
480        $status = $rows[0]['status'];
481        if ($status !== 'submitted' ) $changesAwaiting = false;
482
483        return $changesAwaiting;
484    }
485
486    function hasUpstreamCacheTimedOut()
487    {
488        $hasCacheTimedOut = false;
489
490        $res = $this->loadSqlite();
491        if (!$res) return;
492
493        $sql = "SELECT timestamp FROM git WHERE repo = 'upstream';";
494        $res = $this->sqlite->query($sql);
495        $rows = $this->sqlite->res2arr($res);
496        $timestamp = $rows[0]['timestamp'];
497        if ($timestamp < time() - (60 * 60))  // 60 seconds x 60 minutes = 1 hour
498        {
499            $hasCacheTimedOut = true;
500        }
501
502        return $hasCacheTimedOut;
503    }
504
505    function readUpstreamStatusFromCache() {
506        $updatesAvailable = true;
507
508        $res = $this->loadSqlite();
509        if (!$res) return;
510
511        $sql = "SELECT status FROM git WHERE repo = 'upstream'";
512        $res = $this->sqlite->query($sql);
513        $rows = $this->sqlite->res2arr($res);
514        $status = $rows[0]['status'];
515        if ($status === 'clean') $updatesAvailable = false;
516
517        return $updatesAvailable;
518    }
519
520    function CheckForUpstreamUpdates() {
521        global $conf;
522        $this->getConf('');
523
524        $git_exe_path = $conf['plugin']['git']['git_exe_path'];
525        $datapath = $conf['savedir'];
526
527        $res = $this->loadSqlite();
528        if (!$res) return;
529
530        $updatesAvailable = false;
531        if ($this->hasUpstreamCacheTimedOut())
532        {
533            $repo = new GitRepo($datapath);
534            $repo->git_path = $git_exe_path;
535
536            if ($repo->test_origin() === false) {
537                msg('Repository seems to have an invalid remote (origin)');
538                return $updatesAvailable;
539            }
540
541            $repo->fetch();
542            $log = $repo->get_log();
543
544            if ($log !== "")
545            {
546                $updatesAvailable = true;
547                $sql = "INSERT OR REPLACE INTO git (repo, timestamp, status ) VALUES ('upstream', ".time().", 'alert');";
548                $this->sqlite->query($sql);
549            }
550            else
551            {
552                $sql = "INSERT OR REPLACE INTO git (repo, timestamp, status ) VALUES ('upstream', ".time().", 'clean');";
553                $this->sqlite->query($sql);
554            }
555        }
556        else
557        {
558            $updatesAvailable = $this->readUpstreamStatusFromCache();
559        }
560        return $updatesAvailable;
561    }
562}
563