1<?php
2/**
3 * Conflict Merger Plugin for Dokuwiki
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Daniel Calviño Sánchez <danxuliu@gmail.com>
7 */
8
9// must be run within Dokuwiki
10if (!defined('DOKU_INC')) die();
11
12if (!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN',DOKU_INC.'lib/plugins/');
13require_once(DOKU_PLUGIN.'action.php');
14
15require_once(DOKU_INC.'inc/html.php');
16require_once(DOKU_INC.'inc/infoutils.php');
17require_once(DOKU_INC.'inc/pageutils.php');
18
19if (!defined('NL')) define('NL',"\n");
20
21/**
22 * Action plugin for Dokuwiki to automatically merge changes with latest
23 * revision when saving a document.
24 *
25 * This plugin intercepts 'edit' and 'save' actions and redirects them to a
26 * custom action, 'conflictSolving', when needed. That happens when some changes
27 * were made to the document while it was being edited (being it a pure edition,
28 * or a conflict solving page).
29 *
30 * When a 'conflictSolving' action is received, a conflict solving page is
31 * shown. If the conflict could be solved, the text being edited and the latest
32 * version of the document are merged. Otherwise, no merging happens (when the
33 * changes conflict, or when the solving failed). The page also shows the
34 * differences between versions, and buttons to save the text, make further
35 * editions or cancel the edition.
36 *
37 * The edit form is bypassed to use the text already being worked on (for
38 * example, editting a conflict not mergeable), instead of the latest version of
39 * the page.
40 *
41 * It uses diff3 command. The path to it can be configured through the
42 * configuration plugin.
43 */
44class action_plugin_conflictmerger extends DokuWiki_Action_Plugin {
45
46    /**
47     * Returns the information about conflictmerger plugin.
48     *
49     * @return The information about conflictmerger plugin.
50     */
51    function getInfo() {
52        return array(
53                'author' => 'Daniel Calviño Sánchez',
54                'email'  => 'danxuliu@gmail.com',
55                'date'   => @file_get_contents(DOKU_PLUGIN.'conflictmerger/VERSION'),
56                'name'   => 'Conflict Merger Plugin (action component)',
57                'desc'   => 'Solves, when possible, edition conflicts automatically merging the changes using diff3',
58                'url'    => 'http://wiki.splitbrain.org/plugin:conflictmerger',
59                );
60    }
61
62    /**
63     * Registers handlers for several events.
64     */
65    function register(&$contr) {
66        $contr->register_hook(
67                'ACTION_ACT_PREPROCESS',
68                'BEFORE',
69                $this,
70                'handle_action_act_preprocess',
71                array()
72                );
73        $contr->register_hook(
74                'TPL_ACT_UNKNOWN',
75                'BEFORE',
76                $this,
77                'handle_tpl_act_unknown',
78                array()
79                );
80        $contr->register_hook(
81                'HTML_EDITFORM_OUTPUT',
82                'BEFORE',
83                $this,
84                'handle_html_editform_output',
85                array()
86                );
87    }
88
89    /**
90     * Fires "conflictSolving" events when needed.
91     * A conflict appears in edit and save actions when the page was modified
92     * after the edit or conflict forms were shown. That is, even if the page is
93     * modified again while the user is looking to a conflict form, a new
94     * conflict form will appear informing the user about it.
95     *
96     * Also, note the following scenario: user A starts editing a page, user B
97     * edits and saves the page, and user A saves the page. A conflict form will
98     * be shown to user A, as the page was changed while it was being edited.
99     * Now, the user A decides to further edit the page.
100     *
101     * If the merge was successfull, when the user hit save button in the edit
102     * form, the text will be directly saved (provided no other user changes the
103     * page in the meantime). That is, now the revision to check conflicts
104     * against is the revision the text was merged with, instead of the revision
105     * of the page when the fresh edition was started.
106     *
107     * However, it is different in the case of an unsuccessful merge. In that
108     * case, as the contents weren't merged, the revision to check conflicts
109     * against is the revision of the page when the fresh edition was started.
110     * So even if the text is edited to avoid conflicts, a conflict form will
111     * appear, although a successful one. Once a successful merge form appears,
112     * the revision to check conflicts against is updated to the latest revision
113     * of the page.
114     *
115     * @param event The TPL_ACT_UNKNOWN event.
116     * @param param The parameters of the event.
117     */
118    function handle_action_act_preprocess(&$event, $param) {
119        //$event->data action may come as an array, so it must be cleaned
120        $action = $this->cleanAction($event->data);
121
122        if ($action != 'edit' && $action != 'save') {
123            return;
124        }
125
126        global $DATE;
127        global $INFO;
128
129        if ($DATE == 0 || $INFO['lastmod'] <= $DATE) {
130            return;
131        }
132
133        if ($action == 'edit' && $_REQUEST['conflictDate'] == $INFO['lastmod']) {
134            return;
135        }
136
137        if ($action == 'save' && $_REQUEST['conflictDate'] == $INFO['lastmod']) {
138            $DATE = $_REQUEST['conflictDate'];
139            return;
140        }
141
142        $event->data = 'conflictSolving';
143        $event->preventDefault();
144    }
145
146    /**
147     * Handles "conflictSolving" actions.
148     * Shows the conflict solving area and prevents the default handling of the
149     * event.
150     *
151     * @param event The TPL_ACT_UNKNOWN event.
152     * @param param The parameters of the event.
153     */
154    function handle_tpl_act_unknown(&$event, $param) {
155        if ($event->data != 'conflictSolving') {
156            return;
157        }
158
159        global $PRE;
160        global $SUF;
161        global $TEXT;
162
163        $this->html_conflict_solving(con($PRE,$TEXT,$SUF));
164        $event->preventDefault();
165    }
166
167    /**
168     * Bypasses the text to be edited set by Dokuwiki when needed.
169     * When edit form is created, the text is set to the content of the latest
170     * revision of the page. However, in this plugin the edit page can also be
171     * shown from the conflict solving page, to further edit the text after a
172     * conflict was detected.
173     *
174     * In those cases, that is, when wikitext parameter is already set, the text
175     * in the edit form is changed to the text the user is working on instead of
176     * the contents of the latest revision of the page.
177     *
178     * @param event The HTML_EDITFORM_OUTPUT event.
179     * @param param The parameters of the event.
180     */
181    function handle_html_editform_output(&$event, $param) {
182        global $_POST;
183        global $INFO;
184
185        if (isset($_POST['wikitext'])) {
186            $attr = array('tabindex'=>'1');
187            $wr = $INFO['writable'] && !$INFO['locked'];
188            if (!$wr) $attr['readonly'] = 'readonly';
189
190            $position = $event->data->findElementById("wiki__text");
191            $wikitext = form_makeWikiText(cleanText($_POST['wikitext']), $attr);
192            $event->data->replaceElement($position, $wikitext);
193        }
194    }
195
196    /**
197     * Got from Dokuwiki 2008-05-05 (inc/actions.php, function act_clean)
198     *
199     * Cleans an action.
200     * Some actions may come as an array due to being created like "do[action]".
201     * It returns the cleaned action as a single string with just the action.
202     *
203     * @param action The action to clean.
204     * @return The cleaned action.
205     * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
206     * @author     Andreas Gohr <andi@splitbrain.org>
207     */
208    function cleanAction($action) {
209        if (is_array($action)) {
210            list($action) = array_keys($action);
211        }
212
213        $action = strtolower($action);
214        $action = preg_replace('/[^1-9a-z_]+/', '', $action);
215
216        return $action;
217    }
218
219    /**
220     * Shows a conflict solving page.
221     * The page consists of an informative text explaining whether the text
222     * could be merged or not, the differences between the latest revision of
223     * the page and the text being edited, and a group of buttons to save the
224     * changes, further edit the text or cancel all the changes.
225     *
226     * If the execution of diff3 fails, it reverts to standard behaviour and the
227     * page shown is the default Dokuwiki conflict page.
228     *
229     * @param text The current text.
230     */
231    function html_conflict_solving($text) {
232        global $_REQUEST;
233        global $DATE;
234        global $ID;
235        global $INFO;
236        global $lang;
237        global $SUM;
238
239        if ($this->merge($ID, rawWiki($ID, $DATE), $text, rawWiki($ID, ''), $result)) {
240            print $this->locale_xhtml('conflict-solved');
241
242            $text = $result;
243            $DATE = $INFO['lastmod'];
244            $conflictDate = 0;
245        } else if ($result != '') {
246            print $this->locale_xhtml('conflict-unsolved');
247
248            $conflictDate = $INFO['lastmod'];
249        } else {
250            html_conflict($text, $SUM);
251            html_diff($text, false);
252            return;
253        }
254
255        html_diff($text, false);
256
257        print '<div class="centeralign">'.NL;
258        $form = new Doku_Form('dw__editform');
259        $form->addHidden('id', $ID);
260        $form->addHidden('date', $DATE);
261        $form->addHidden('conflictDate', $conflictDate);
262        $form->addHidden('wikitext', $text);
263        $form->addHidden('summary', $SUM);
264        $form->addElement(form_makeButton('submit', 'save', $lang['btn_save'], array('accesskey'=>'s')));
265        $form->addElement(form_makeButton('submit', 'edit', $lang['btn_edit'], array('accesskey'=>'e')));
266        $form->addElement(form_makeButton('submit', 'cancel', $lang['btn_cancel']));
267        html_form('conflict', $form);
268        print '</div>'.NL;
269    }
270
271    /**
272     * Adapted from MediaWiki 1.13.2. (includes/GlobalFunctions.php)
273     *
274     * @license  You may copy this code freely under the conditions of the GPL.
275     *
276     * Attempts to merge differences between three texts.
277     *
278     * It merges the changes happened from "old" to "yours" with "mine", and stores
279     * the merged text in "result". If the changes happened from "old" to "yours"
280     * overlap with those happened from "old" to "mine" there is a conflict, and the
281     * changes can't be automatically merged. That is, it can only merge changes
282     * happened in separated areas of the texts ("old" is a previous version of both
283     * "mine" and "yours").
284     *
285     * Differences are computed in a line by line basis. That is, changes in the
286     * same line, even if they don't overlap, are seen as a conflict.
287     *
288     * Merging is done using diff3 executable. It can be configured through 'diff3'
289     * plugin configuration key. Temporary files containing old, mine and yours
290     * texts are created to be used with diff3.
291     *
292     * Further information about how merging is done can be got in diff3 info page.
293     *
294     * Even when a conflict is detected, result contains some merging attempt. Only
295     * when a failure happened (for example, if diff3 wasn't found), result is
296     * empty.
297     *
298     * @param id The id of the text to merge.
299     * @param old The base text.
300     * @param mine The first modified text.
301     * @param yours The second modified text.
302     * @param result The merged text, if any.
303     * @return True for a clean merge and false for failure or conflict.
304     */
305    function merge( $id, $old, $mine, $yours, &$result ) {
306        $diff3 = $this->getConf('diff3');
307
308        $result = '';
309
310        # This check may also protect against code injection in
311        # case of broken installations.
312        if ( !isset($diff3) || !file_exists( $diff3 ) ) {
313            msg( "diff3 not found\n", -1 );
314            return false;
315        }
316
317        # Make temporary files
318        //TODO Is there any function to create temporary files? Modify unit tests as necessary
319        $baseName = wikiFN( $id );
320        $myTextFile = fopen( $myTextName = ($baseName . '-merge-mine'), 'w' );
321        $oldTextFile = fopen( $oldTextName = ($baseName . '-merge-old'), 'w' );
322        $yourTextFile = fopen( $yourTextName = ($baseName .  '-merge-your'), 'w' );
323
324        fwrite( $myTextFile, $mine ); fclose( $myTextFile );
325        fwrite( $oldTextFile, $old ); fclose( $oldTextFile );
326        fwrite( $yourTextFile, $yours ); fclose( $yourTextFile );
327
328        # Check for a conflict
329        $cmd = $diff3 . ' -a --overlap-only ' .
330            escapeshellarg( $myTextName ) . ' ' .
331            escapeshellarg( $oldTextName ) . ' ' .
332            escapeshellarg( $yourTextName );
333//          FIXME: In MediaWiki code it uses wfEscapeShellArg, which includes
334//          some special code for Windows
335//          wfEscapeShellArg( $myTextName ) . ' ' .
336//          wfEscapeShellArg( $oldTextName ) . ' ' .
337//          wfEscapeShellArg( $yourTextName );
338        $handle = popen( $cmd, 'r' );
339
340        if ( fgets( $handle, 1024 ) ) {
341            $conflict = true;
342        } else {
343            $conflict = false;
344        }
345        pclose( $handle );
346
347        # Merge differences
348        $cmd = $diff3 . ' -a -e --merge ' .
349            escapeshellarg( $myTextName ) . ' ' .
350            escapeshellarg( $oldTextName ) . ' ' .
351            escapeshellarg( $yourTextName );
352//          FIXME: In MediaWiki code it uses wfEscapeShellArg, which includes
353//          some special code for Windows
354//          wfEscapeShellArg( $myTextName, $oldTextName, $yourTextName );
355        $handle = popen( $cmd, 'r' );
356
357        do {
358            $data = fread( $handle, 8192 );
359            if ( strlen( $data ) == 0 ) {
360                break;
361            }
362            $result .= $data;
363        } while ( true );
364        pclose( $handle );
365        unlink( $myTextName ); unlink( $oldTextName ); unlink( $yourTextName );
366
367        if ( $result === '' && $old !== '' && $conflict == false ) {
368            msg( "Unexpected null result from diff3. Command: $cmd\n", -1 );
369            $conflict = true;
370        }
371        return !$conflict;
372    }
373}
374
375//Setup VIM: ex: et ts=4 enc=utf-8 :
376