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