xref: /plugin/todomover/action.php (revision 04b1da3789b6d07104d2cf804afd571d57195d24)
1*04b1da37SPedro Lopes<?php
2*04b1da37SPedro Lopes/**
3*04b1da37SPedro Lopes * DokuWiki Plugin tododone (Action Component)
4*04b1da37SPedro Lopes *
5*04b1da37SPedro Lopes * Adds a page action that moves todo items with a hashtag in the opening
6*04b1da37SPedro Lopes * <todo ...> tag to a final "## done" section.
7*04b1da37SPedro Lopes *
8*04b1da37SPedro Lopes * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
9*04b1da37SPedro Lopes */
10*04b1da37SPedro Lopes
11*04b1da37SPedro Lopesuse dokuwiki\Extension\ActionPlugin;
12*04b1da37SPedro Lopesuse dokuwiki\Extension\Event;
13*04b1da37SPedro Lopesuse dokuwiki\Extension\EventHandler;
14*04b1da37SPedro Lopes
15*04b1da37SPedro Lopesif (!defined('DOKU_INC')) die();
16*04b1da37SPedro Lopes
17*04b1da37SPedro Lopesclass action_plugin_tododone extends ActionPlugin
18*04b1da37SPedro Lopes{
19*04b1da37SPedro Lopes    const ACTION = 'movetaggedtodos';
20*04b1da37SPedro Lopes    const DONE_HEADING = '## done';
21*04b1da37SPedro Lopes
22*04b1da37SPedro Lopes    /**
23*04b1da37SPedro Lopes     * Register DokuWiki event hooks.
24*04b1da37SPedro Lopes     *
25*04b1da37SPedro Lopes     * @param EventHandler $controller
26*04b1da37SPedro Lopes     * @return void
27*04b1da37SPedro Lopes     */
28*04b1da37SPedro Lopes    public function register(EventHandler $controller)
29*04b1da37SPedro Lopes    {
30*04b1da37SPedro Lopes        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAction');
31*04b1da37SPedro Lopes        $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'addMenuButton');
32*04b1da37SPedro Lopes        $controller->register_hook('TEMPLATE_PAGETOOLS_DISPLAY', 'BEFORE', $this, 'addLegacyButton');
33*04b1da37SPedro Lopes    }
34*04b1da37SPedro Lopes
35*04b1da37SPedro Lopes    /**
36*04b1da37SPedro Lopes     * Add the action to modern page menus.
37*04b1da37SPedro Lopes     *
38*04b1da37SPedro Lopes     * @param Event $event
39*04b1da37SPedro Lopes     * @param mixed $param
40*04b1da37SPedro Lopes     * @return void
41*04b1da37SPedro Lopes     */
42*04b1da37SPedro Lopes    public function addMenuButton(Event $event, $param)
43*04b1da37SPedro Lopes    {
44*04b1da37SPedro Lopes        global $ID, $REV;
45*04b1da37SPedro Lopes
46*04b1da37SPedro Lopes        if (($event->data['view'] ?? '') !== 'page') return;
47*04b1da37SPedro Lopes        if ($REV) return;
48*04b1da37SPedro Lopes        if (auth_quickaclcheck($ID) < AUTH_EDIT) return;
49*04b1da37SPedro Lopes
50*04b1da37SPedro Lopes        try {
51*04b1da37SPedro Lopes            array_splice($event->data['items'], 1, 0, [new \dokuwiki\plugin\tododone\MenuItem()]);
52*04b1da37SPedro Lopes        } catch (\Exception $ignored) {
53*04b1da37SPedro Lopes            // Some templates/menu configurations may reject custom items. The legacy
54*04b1da37SPedro Lopes            // pagetools hook below is kept as a fallback.
55*04b1da37SPedro Lopes        }
56*04b1da37SPedro Lopes    }
57*04b1da37SPedro Lopes
58*04b1da37SPedro Lopes    /**
59*04b1da37SPedro Lopes     * Add the action to legacy page tool menus.
60*04b1da37SPedro Lopes     *
61*04b1da37SPedro Lopes     * @param Event $event
62*04b1da37SPedro Lopes     * @param mixed $param
63*04b1da37SPedro Lopes     * @return void
64*04b1da37SPedro Lopes     */
65*04b1da37SPedro Lopes    public function addLegacyButton(Event $event, $param)
66*04b1da37SPedro Lopes    {
67*04b1da37SPedro Lopes        global $ID, $REV;
68*04b1da37SPedro Lopes
69*04b1da37SPedro Lopes        if (($event->data['view'] ?? '') !== 'main') return;
70*04b1da37SPedro Lopes        if ($REV) return;
71*04b1da37SPedro Lopes        if (auth_quickaclcheck($ID) < AUTH_EDIT) return;
72*04b1da37SPedro Lopes        if (!isset($event->data['items']) || !is_array($event->data['items'])) return;
73*04b1da37SPedro Lopes
74*04b1da37SPedro Lopes        $url = wl($ID, ['do' => self::ACTION, 'sectok' => getSecurityToken()], false, '&');
75*04b1da37SPedro Lopes        $event->data['items']['movetaggedtodos'] =
76*04b1da37SPedro Lopes            '<li><a href="' . hsc($url) . '" class="action movetaggedtodos" rel="nofollow">' .
77*04b1da37SPedro Lopes            '<span>Move tagged todos</span></a></li>';
78*04b1da37SPedro Lopes    }
79*04b1da37SPedro Lopes
80*04b1da37SPedro Lopes    /**
81*04b1da37SPedro Lopes     * Handle ?do=movetaggedtodos.
82*04b1da37SPedro Lopes     *
83*04b1da37SPedro Lopes     * @param Event $event
84*04b1da37SPedro Lopes     * @param mixed $param
85*04b1da37SPedro Lopes     * @return void
86*04b1da37SPedro Lopes     */
87*04b1da37SPedro Lopes    public function handleAction(Event $event, $param)
88*04b1da37SPedro Lopes    {
89*04b1da37SPedro Lopes        global $ID, $REV;
90*04b1da37SPedro Lopes
91*04b1da37SPedro Lopes        if ($event->data !== self::ACTION) return;
92*04b1da37SPedro Lopes
93*04b1da37SPedro Lopes        $event->preventDefault();
94*04b1da37SPedro Lopes        $event->stopPropagation();
95*04b1da37SPedro Lopes
96*04b1da37SPedro Lopes        if ($REV) {
97*04b1da37SPedro Lopes            msg('Todo Done Mover cannot modify an old page revision.', -1);
98*04b1da37SPedro Lopes            $this->redirectToPage();
99*04b1da37SPedro Lopes        }
100*04b1da37SPedro Lopes
101*04b1da37SPedro Lopes        if (!checkSecurityToken()) {
102*04b1da37SPedro Lopes            $this->redirectToPage();
103*04b1da37SPedro Lopes        }
104*04b1da37SPedro Lopes
105*04b1da37SPedro Lopes        $info = pageinfo();
106*04b1da37SPedro Lopes        if (empty($info['exists'])) {
107*04b1da37SPedro Lopes            msg('Todo Done Mover: page does not exist.', -1);
108*04b1da37SPedro Lopes            $this->redirectToPage();
109*04b1da37SPedro Lopes        }
110*04b1da37SPedro Lopes
111*04b1da37SPedro Lopes        if (empty($info['editable']) || auth_quickaclcheck($ID) < AUTH_EDIT) {
112*04b1da37SPedro Lopes            msg('Todo Done Mover: you do not have permission to edit this page, or it is locked.', -1);
113*04b1da37SPedro Lopes            $this->redirectToPage();
114*04b1da37SPedro Lopes        }
115*04b1da37SPedro Lopes
116*04b1da37SPedro Lopes        $oldText = io_readWikiPage(wikiFN($ID), $ID);
117*04b1da37SPedro Lopes        list($newText, $movedCount) = $this->moveTaggedTodos($oldText);
118*04b1da37SPedro Lopes
119*04b1da37SPedro Lopes        if ($movedCount === 0 || $newText === $oldText) {
120*04b1da37SPedro Lopes            msg('Todo Done Mover: no <todo #tag> items found to move.', 0);
121*04b1da37SPedro Lopes            $this->redirectToPage();
122*04b1da37SPedro Lopes        }
123*04b1da37SPedro Lopes
124*04b1da37SPedro Lopes        saveWikiText(
125*04b1da37SPedro Lopes            $ID,
126*04b1da37SPedro Lopes            $newText,
127*04b1da37SPedro Lopes            'Moved ' . $movedCount . ' tagged todo item(s) to ' . self::DONE_HEADING,
128*04b1da37SPedro Lopes            true
129*04b1da37SPedro Lopes        );
130*04b1da37SPedro Lopes
131*04b1da37SPedro Lopes        msg('Todo Done Mover: moved ' . $movedCount . ' tagged todo item(s) to ' . self::DONE_HEADING . '.', 1);
132*04b1da37SPedro Lopes        $this->redirectToPage();
133*04b1da37SPedro Lopes    }
134*04b1da37SPedro Lopes
135*04b1da37SPedro Lopes    /**
136*04b1da37SPedro Lopes     * Move whole-line todo items whose opening tag contains a whitespace-prefixed hashtag.
137*04b1da37SPedro Lopes     *
138*04b1da37SPedro Lopes     * Examples moved:
139*04b1da37SPedro Lopes     *   <todo #plopes>a done today to move</todo>
140*04b1da37SPedro Lopes     *
141*04b1da37SPedro Lopes     * Examples not moved:
142*04b1da37SPedro Lopes     *   <todo>not done todo</todo>
143*04b1da37SPedro Lopes     *   <todo due:monday>also not done</todo>
144*04b1da37SPedro Lopes     *
145*04b1da37SPedro Lopes     * @param string $text Raw page source
146*04b1da37SPedro Lopes     * @return array [newText, movedCount]
147*04b1da37SPedro Lopes     */
148*04b1da37SPedro Lopes    public function moveTaggedTodos($text)
149*04b1da37SPedro Lopes    {
150*04b1da37SPedro Lopes        $eol = (strpos($text, "\r\n") !== false) ? "\r\n" : "\n";
151*04b1da37SPedro Lopes        $work = str_replace(["\r\n", "\r"], "\n", $text);
152*04b1da37SPedro Lopes        $moved = [];
153*04b1da37SPedro Lopes
154*04b1da37SPedro Lopes        // Whole-line todo blocks only, with optional bullet marker. The hashtag must
155*04b1da37SPedro Lopes        // appear inside the opening <todo ...> tag as a token, for example " #done".
156*04b1da37SPedro Lopes        $pattern = '/^[^\S\n]*(?:[-*]\s+)?<todo\b(?=[^>\n]*(?<!\S)#[A-Za-z0-9][A-Za-z0-9_-]*)[^>\n]*>.*?<\/todo>[^\S\n]*(?:\n|$)/ims';
157*04b1da37SPedro Lopes
158*04b1da37SPedro Lopes        $remaining = preg_replace_callback($pattern, function ($match) use (&$moved) {
159*04b1da37SPedro Lopes            $item = trim($match[0], "\n");
160*04b1da37SPedro Lopes            $item = rtrim($item, " \t\n");
161*04b1da37SPedro Lopes            if (trim($item) !== '') {
162*04b1da37SPedro Lopes                $moved[] = $item;
163*04b1da37SPedro Lopes            }
164*04b1da37SPedro Lopes            return '';
165*04b1da37SPedro Lopes        }, $work);
166*04b1da37SPedro Lopes
167*04b1da37SPedro Lopes        if (count($moved) === 0) {
168*04b1da37SPedro Lopes            return [$text, 0];
169*04b1da37SPedro Lopes        }
170*04b1da37SPedro Lopes
171*04b1da37SPedro Lopes        $remaining = rtrim($remaining, "\n");
172*04b1da37SPedro Lopes
173*04b1da37SPedro Lopes        // If the final Markdown-style level-2 section is not already "## done",
174*04b1da37SPedro Lopes        // create a new bottom section. This keeps the moved todos at page bottom.
175*04b1da37SPedro Lopes        if (!$this->finalLevelTwoSectionIsDone($remaining)) {
176*04b1da37SPedro Lopes            if ($remaining !== '') {
177*04b1da37SPedro Lopes                $remaining .= "\n\n";
178*04b1da37SPedro Lopes            }
179*04b1da37SPedro Lopes            $remaining .= self::DONE_HEADING;
180*04b1da37SPedro Lopes        }
181*04b1da37SPedro Lopes
182*04b1da37SPedro Lopes        if ($remaining !== '' && substr($remaining, -1) !== "\n") {
183*04b1da37SPedro Lopes            $remaining .= "\n";
184*04b1da37SPedro Lopes        }
185*04b1da37SPedro Lopes        $remaining .= implode("\n", $moved) . "\n";
186*04b1da37SPedro Lopes
187*04b1da37SPedro Lopes        return [str_replace("\n", $eol, $remaining), count($moved)];
188*04b1da37SPedro Lopes    }
189*04b1da37SPedro Lopes
190*04b1da37SPedro Lopes    /**
191*04b1da37SPedro Lopes     * Return true when the last Markdown-style level-2 section is exactly "## done".
192*04b1da37SPedro Lopes     *
193*04b1da37SPedro Lopes     * @param string $text Normalized source using \n line endings
194*04b1da37SPedro Lopes     * @return bool
195*04b1da37SPedro Lopes     */
196*04b1da37SPedro Lopes    private function finalLevelTwoSectionIsDone($text)
197*04b1da37SPedro Lopes    {
198*04b1da37SPedro Lopes        if (!preg_match_all('/^##[ \t]+(.+?)[ \t]*$/im', $text, $matches, PREG_OFFSET_CAPTURE)) {
199*04b1da37SPedro Lopes            return false;
200*04b1da37SPedro Lopes        }
201*04b1da37SPedro Lopes
202*04b1da37SPedro Lopes        $lastIndex = count($matches[1]) - 1;
203*04b1da37SPedro Lopes        $lastHeadingText = strtolower(trim($matches[1][$lastIndex][0]));
204*04b1da37SPedro Lopes
205*04b1da37SPedro Lopes        return $lastHeadingText === 'done';
206*04b1da37SPedro Lopes    }
207*04b1da37SPedro Lopes
208*04b1da37SPedro Lopes    /**
209*04b1da37SPedro Lopes     * Redirect back to the current wiki page and stop request processing.
210*04b1da37SPedro Lopes     *
211*04b1da37SPedro Lopes     * @return void
212*04b1da37SPedro Lopes     */
213*04b1da37SPedro Lopes    private function redirectToPage()
214*04b1da37SPedro Lopes    {
215*04b1da37SPedro Lopes        global $ID;
216*04b1da37SPedro Lopes        send_redirect(wl($ID, '', false, '&'));
217*04b1da37SPedro Lopes        exit;
218*04b1da37SPedro Lopes    }
219*04b1da37SPedro Lopes}
220