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