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