xref: /plugin/todomover/action.php (revision 7d73518ef18365bbc0ab4394fbba4037e544a0b3)
104b1da37SPedro Lopes<?php
204b1da37SPedro Lopes/**
3*7d73518eSPedro Lopes * DokuWiki Plugin todomover (Action Component)
404b1da37SPedro Lopes *
504b1da37SPedro Lopes * Adds a page action that moves todo items with a hashtag in the opening
604b1da37SPedro Lopes * <todo ...> tag to a final "## done" section.
704b1da37SPedro Lopes *
804b1da37SPedro Lopes * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
904b1da37SPedro Lopes */
1004b1da37SPedro Lopes
1104b1da37SPedro Lopesuse dokuwiki\Extension\ActionPlugin;
1204b1da37SPedro Lopesuse dokuwiki\Extension\Event;
1304b1da37SPedro Lopesuse dokuwiki\Extension\EventHandler;
1404b1da37SPedro Lopes
1504b1da37SPedro Lopesif (!defined('DOKU_INC')) die();
1604b1da37SPedro Lopes
17*7d73518eSPedro Lopesclass action_plugin_todomover extends ActionPlugin
1804b1da37SPedro Lopes{
1904b1da37SPedro Lopes    const ACTION = 'movetaggedtodos';
2004b1da37SPedro Lopes    const DONE_HEADING = '## done';
2104b1da37SPedro Lopes
2204b1da37SPedro Lopes    /**
2304b1da37SPedro Lopes     * Register DokuWiki event hooks.
2404b1da37SPedro Lopes     *
2504b1da37SPedro Lopes     * @param EventHandler $controller
2604b1da37SPedro Lopes     * @return void
2704b1da37SPedro Lopes     */
2804b1da37SPedro Lopes    public function register(EventHandler $controller)
2904b1da37SPedro Lopes    {
3004b1da37SPedro Lopes        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleAction');
3104b1da37SPedro Lopes        $controller->register_hook('MENU_ITEMS_ASSEMBLY', 'AFTER', $this, 'addMenuButton');
3204b1da37SPedro Lopes        $controller->register_hook('TEMPLATE_PAGETOOLS_DISPLAY', 'BEFORE', $this, 'addLegacyButton');
3304b1da37SPedro Lopes    }
3404b1da37SPedro Lopes
3504b1da37SPedro Lopes    /**
3604b1da37SPedro Lopes     * Add the action to modern page menus.
3704b1da37SPedro Lopes     *
3804b1da37SPedro Lopes     * @param Event $event
3904b1da37SPedro Lopes     * @param mixed $param
4004b1da37SPedro Lopes     * @return void
4104b1da37SPedro Lopes     */
4204b1da37SPedro Lopes    public function addMenuButton(Event $event, $param)
4304b1da37SPedro Lopes    {
4404b1da37SPedro Lopes        global $ID, $REV;
4504b1da37SPedro Lopes
4604b1da37SPedro Lopes        if (($event->data['view'] ?? '') !== 'page') return;
4704b1da37SPedro Lopes        if ($REV) return;
4804b1da37SPedro Lopes        if (auth_quickaclcheck($ID) < AUTH_EDIT) return;
4904b1da37SPedro Lopes
5004b1da37SPedro Lopes        try {
51*7d73518eSPedro Lopes            array_splice($event->data['items'], 1, 0, [new \dokuwiki\plugin\todomover\MenuItem()]);
5204b1da37SPedro Lopes        } catch (\Exception $ignored) {
5304b1da37SPedro Lopes            // Some templates/menu configurations may reject custom items. The legacy
5404b1da37SPedro Lopes            // pagetools hook below is kept as a fallback.
5504b1da37SPedro Lopes        }
5604b1da37SPedro Lopes    }
5704b1da37SPedro Lopes
5804b1da37SPedro Lopes    /**
5904b1da37SPedro Lopes     * Add the action to legacy page tool menus.
6004b1da37SPedro Lopes     *
6104b1da37SPedro Lopes     * @param Event $event
6204b1da37SPedro Lopes     * @param mixed $param
6304b1da37SPedro Lopes     * @return void
6404b1da37SPedro Lopes     */
6504b1da37SPedro Lopes    public function addLegacyButton(Event $event, $param)
6604b1da37SPedro Lopes    {
6704b1da37SPedro Lopes        global $ID, $REV;
6804b1da37SPedro Lopes
6904b1da37SPedro Lopes        if (($event->data['view'] ?? '') !== 'main') return;
7004b1da37SPedro Lopes        if ($REV) return;
7104b1da37SPedro Lopes        if (auth_quickaclcheck($ID) < AUTH_EDIT) return;
7204b1da37SPedro Lopes        if (!isset($event->data['items']) || !is_array($event->data['items'])) return;
7304b1da37SPedro Lopes
7404b1da37SPedro Lopes        $url = wl($ID, ['do' => self::ACTION, 'sectok' => getSecurityToken()], false, '&');
7504b1da37SPedro Lopes        $event->data['items']['movetaggedtodos'] =
7604b1da37SPedro Lopes            '<li><a href="' . hsc($url) . '" class="action movetaggedtodos" rel="nofollow">' .
7704b1da37SPedro Lopes            '<span>Move tagged todos</span></a></li>';
7804b1da37SPedro Lopes    }
7904b1da37SPedro Lopes
8004b1da37SPedro Lopes    /**
8104b1da37SPedro Lopes     * Handle ?do=movetaggedtodos.
8204b1da37SPedro Lopes     *
8304b1da37SPedro Lopes     * @param Event $event
8404b1da37SPedro Lopes     * @param mixed $param
8504b1da37SPedro Lopes     * @return void
8604b1da37SPedro Lopes     */
8704b1da37SPedro Lopes    public function handleAction(Event $event, $param)
8804b1da37SPedro Lopes    {
8904b1da37SPedro Lopes        global $ID, $REV;
9004b1da37SPedro Lopes
9104b1da37SPedro Lopes        if ($event->data !== self::ACTION) return;
9204b1da37SPedro Lopes
9304b1da37SPedro Lopes        $event->preventDefault();
9404b1da37SPedro Lopes        $event->stopPropagation();
9504b1da37SPedro Lopes
9604b1da37SPedro Lopes        if ($REV) {
9704b1da37SPedro Lopes            msg('Todo Done Mover cannot modify an old page revision.', -1);
9804b1da37SPedro Lopes            $this->redirectToPage();
9904b1da37SPedro Lopes        }
10004b1da37SPedro Lopes
10104b1da37SPedro Lopes        if (!checkSecurityToken()) {
10204b1da37SPedro Lopes            $this->redirectToPage();
10304b1da37SPedro Lopes        }
10404b1da37SPedro Lopes
10504b1da37SPedro Lopes        $info = pageinfo();
10604b1da37SPedro Lopes        if (empty($info['exists'])) {
10704b1da37SPedro Lopes            msg('Todo Done Mover: page does not exist.', -1);
10804b1da37SPedro Lopes            $this->redirectToPage();
10904b1da37SPedro Lopes        }
11004b1da37SPedro Lopes
11104b1da37SPedro Lopes        if (empty($info['editable']) || auth_quickaclcheck($ID) < AUTH_EDIT) {
11204b1da37SPedro Lopes            msg('Todo Done Mover: you do not have permission to edit this page, or it is locked.', -1);
11304b1da37SPedro Lopes            $this->redirectToPage();
11404b1da37SPedro Lopes        }
11504b1da37SPedro Lopes
11604b1da37SPedro Lopes        $oldText = io_readWikiPage(wikiFN($ID), $ID);
11704b1da37SPedro Lopes        list($newText, $movedCount) = $this->moveTaggedTodos($oldText);
11804b1da37SPedro Lopes
11904b1da37SPedro Lopes        if ($movedCount === 0 || $newText === $oldText) {
12004b1da37SPedro Lopes            msg('Todo Done Mover: no <todo #tag> items found to move.', 0);
12104b1da37SPedro Lopes            $this->redirectToPage();
12204b1da37SPedro Lopes        }
12304b1da37SPedro Lopes
12404b1da37SPedro Lopes        saveWikiText(
12504b1da37SPedro Lopes            $ID,
12604b1da37SPedro Lopes            $newText,
12704b1da37SPedro Lopes            'Moved ' . $movedCount . ' tagged todo item(s) to ' . self::DONE_HEADING,
12804b1da37SPedro Lopes            true
12904b1da37SPedro Lopes        );
13004b1da37SPedro Lopes
13104b1da37SPedro Lopes        msg('Todo Done Mover: moved ' . $movedCount . ' tagged todo item(s) to ' . self::DONE_HEADING . '.', 1);
13204b1da37SPedro Lopes        $this->redirectToPage();
13304b1da37SPedro Lopes    }
13404b1da37SPedro Lopes
13504b1da37SPedro Lopes    /**
13604b1da37SPedro Lopes     * Move whole-line todo items whose opening tag contains a whitespace-prefixed hashtag.
13704b1da37SPedro Lopes     *
13804b1da37SPedro Lopes     * Examples moved:
13904b1da37SPedro Lopes     *   <todo #plopes>a done today to move</todo>
14004b1da37SPedro Lopes     *
14104b1da37SPedro Lopes     * Examples not moved:
14204b1da37SPedro Lopes     *   <todo>not done todo</todo>
14304b1da37SPedro Lopes     *   <todo due:monday>also not done</todo>
14404b1da37SPedro Lopes     *
14504b1da37SPedro Lopes     * @param string $text Raw page source
14604b1da37SPedro Lopes     * @return array [newText, movedCount]
14704b1da37SPedro Lopes     */
14804b1da37SPedro Lopes    public function moveTaggedTodos($text)
14904b1da37SPedro Lopes    {
15004b1da37SPedro Lopes        $eol = (strpos($text, "\r\n") !== false) ? "\r\n" : "\n";
15104b1da37SPedro Lopes        $work = str_replace(["\r\n", "\r"], "\n", $text);
15204b1da37SPedro Lopes        $moved = [];
15304b1da37SPedro Lopes
15404b1da37SPedro Lopes        // Whole-line todo blocks only, with optional bullet marker. The hashtag must
15504b1da37SPedro Lopes        // appear inside the opening <todo ...> tag as a token, for example " #done".
15604b1da37SPedro Lopes        $pattern = '/^[^\S\n]*(?:[-*]\s+)?<todo\b(?=[^>\n]*(?<!\S)#[A-Za-z0-9][A-Za-z0-9_-]*)[^>\n]*>.*?<\/todo>[^\S\n]*(?:\n|$)/ims';
15704b1da37SPedro Lopes
15804b1da37SPedro Lopes        $remaining = preg_replace_callback($pattern, function ($match) use (&$moved) {
15904b1da37SPedro Lopes            $item = trim($match[0], "\n");
16004b1da37SPedro Lopes            $item = rtrim($item, " \t\n");
16104b1da37SPedro Lopes            if (trim($item) !== '') {
16204b1da37SPedro Lopes                $moved[] = $item;
16304b1da37SPedro Lopes            }
16404b1da37SPedro Lopes            return '';
16504b1da37SPedro Lopes        }, $work);
16604b1da37SPedro Lopes
16704b1da37SPedro Lopes        if (count($moved) === 0) {
16804b1da37SPedro Lopes            return [$text, 0];
16904b1da37SPedro Lopes        }
17004b1da37SPedro Lopes
17104b1da37SPedro Lopes        $remaining = rtrim($remaining, "\n");
17204b1da37SPedro Lopes
17304b1da37SPedro Lopes        // If the final Markdown-style level-2 section is not already "## done",
17404b1da37SPedro Lopes        // create a new bottom section. This keeps the moved todos at page bottom.
17504b1da37SPedro Lopes        if (!$this->finalLevelTwoSectionIsDone($remaining)) {
17604b1da37SPedro Lopes            if ($remaining !== '') {
17704b1da37SPedro Lopes                $remaining .= "\n\n";
17804b1da37SPedro Lopes            }
17904b1da37SPedro Lopes            $remaining .= self::DONE_HEADING;
18004b1da37SPedro Lopes        }
18104b1da37SPedro Lopes
18204b1da37SPedro Lopes        if ($remaining !== '' && substr($remaining, -1) !== "\n") {
18304b1da37SPedro Lopes            $remaining .= "\n";
18404b1da37SPedro Lopes        }
18504b1da37SPedro Lopes        $remaining .= implode("\n", $moved) . "\n";
18604b1da37SPedro Lopes
18704b1da37SPedro Lopes        return [str_replace("\n", $eol, $remaining), count($moved)];
18804b1da37SPedro Lopes    }
18904b1da37SPedro Lopes
19004b1da37SPedro Lopes    /**
19104b1da37SPedro Lopes     * Return true when the last Markdown-style level-2 section is exactly "## done".
19204b1da37SPedro Lopes     *
19304b1da37SPedro Lopes     * @param string $text Normalized source using \n line endings
19404b1da37SPedro Lopes     * @return bool
19504b1da37SPedro Lopes     */
19604b1da37SPedro Lopes    private function finalLevelTwoSectionIsDone($text)
19704b1da37SPedro Lopes    {
19804b1da37SPedro Lopes        if (!preg_match_all('/^##[ \t]+(.+?)[ \t]*$/im', $text, $matches, PREG_OFFSET_CAPTURE)) {
19904b1da37SPedro Lopes            return false;
20004b1da37SPedro Lopes        }
20104b1da37SPedro Lopes
20204b1da37SPedro Lopes        $lastIndex = count($matches[1]) - 1;
20304b1da37SPedro Lopes        $lastHeadingText = strtolower(trim($matches[1][$lastIndex][0]));
20404b1da37SPedro Lopes
20504b1da37SPedro Lopes        return $lastHeadingText === 'done';
20604b1da37SPedro Lopes    }
20704b1da37SPedro Lopes
20804b1da37SPedro Lopes    /**
20904b1da37SPedro Lopes     * Redirect back to the current wiki page and stop request processing.
21004b1da37SPedro Lopes     *
21104b1da37SPedro Lopes     * @return void
21204b1da37SPedro Lopes     */
21304b1da37SPedro Lopes    private function redirectToPage()
21404b1da37SPedro Lopes    {
21504b1da37SPedro Lopes        global $ID;
21604b1da37SPedro Lopes        send_redirect(wl($ID, '', false, '&'));
21704b1da37SPedro Lopes        exit;
21804b1da37SPedro Lopes    }
21904b1da37SPedro Lopes}
220