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