1<?php 2 3use dokuwiki\Extension\CLIPlugin; 4use splitbrain\phpcli\Options; 5 6/** 7 * DokuWiki Plugin acknowledge (CLI Component) 8 * 9 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 10 * @author Anna Dabrowska <dokuwiki@cosmocode.de> 11 */ 12class cli_plugin_acknowledge extends CLIPlugin 13{ 14 /** @var helper_plugin_acknowledge */ 15 protected $helper; 16 17 /** 18 * Initialize helper 19 */ 20 public function __construct() 21 { 22 parent::__construct(); 23 $this->helper = plugin_load('helper', 'acknowledge'); 24 } 25 26 /** 27 * @inheritDoc 28 */ 29 protected function setup(Options $options) 30 { 31 $options->setHelp('Maintenance and import tools for the acknowledge plugin'); 32 33 $options->registerCommand( 34 'import-ireadit', 35 'Import read records and ~~IREADIT~~ assignments from the ireadit plugin' 36 ); 37 $options->registerOption( 38 'dry-run', 39 'Only report what would be imported, without writing anything', 40 'd', 41 false, 42 'import-ireadit' 43 ); 44 } 45 46 /** 47 * @inheritDoc 48 */ 49 protected function main(Options $options) 50 { 51 $cmd = $options->getCmd(); 52 switch ($cmd) { 53 case 'import-ireadit': 54 try { 55 $this->importIreadit((bool)$options->getOpt('dry-run')); 56 } catch (\Exception $e) { 57 $this->fatal($e); 58 } 59 break; 60 default: 61 $this->error('No command provided'); 62 echo $options->help(); 63 exit(1); 64 } 65 } 66 67 /** 68 * Import acknowledgements and assignments from the ireadit plugin 69 * 70 * @param bool $dryRun whether to only report instead of writing 71 * @return void 72 * @throws Exception 73 */ 74 protected function importIreadit(bool $dryRun): void 75 { 76 /** @var helper_plugin_ireadit_db $ireaditDb */ 77 $ireaditDb = plugin_load('helper', 'ireadit_db'); 78 if ($ireaditDb === null) { 79 throw new \RuntimeException( 80 'The ireaditDb plugin is required but could not be loaded.' 81 ); 82 } 83 84 if ($dryRun) { 85 $this->notice('Running in dry-run mode, no changes will be written'); 86 } 87 88 // make sure the pages table is populated 89 if (!$dryRun) { 90 $this->helper->updatePageIndex(); 91 } 92 93 [$assignmentCount, $patternCount] = $this->importAssignments($dryRun); 94 $acksCount = $this->importIreaditRecords($ireaditDb, $dryRun); 95 96 $this->success(sprintf( 97 'Done: imported %d ireadit record(s), %d assignment pattern(s) (%d assignee entries)', 98 $acksCount, 99 $patternCount, 100 $assignmentCount 101 )); 102 } 103 104 /** 105 * Import ireadit records into the acks table, keeping only the latest per page+user 106 * 107 * @param helper_plugin_ireadit_db $ireaditDb 108 * @param bool $dryRun 109 * @return int number of records imported (or that would be imported if on dry-run) 110 * @throws Exception 111 */ 112 protected function importIreaditRecords(\helper_plugin_ireadit_db $ireaditDb, bool $dryRun): int 113 { 114 $sqlite = $ireaditDb->getDB(); 115 $res = $sqlite->query('SELECT page, user, rev, tim§estamp FROM ireadit'); 116 if ($res === false) { 117 throw new \RuntimeException('Failed to read records from the ireadit database'); 118 } 119 $rows = $sqlite->res2arr($res); 120 121 // keep only the latest acknowledgement per page+user 122 $latest = []; 123 foreach ($rows as $row) { 124 $time = $row['timestamp'] ? strtotime($row['timestamp']) : (int)$row['rev']; 125 if (!$time) continue; 126 127 // index by page+user; the NUL byte is a safe separator because it can never 128 // appear in a page id or user name, so the two parts can't collide 129 $key = $row['page'] . "\0" . $row['user']; 130 if (!isset($latest[$key]) || $time > $latest[$key]['time']) { 131 $latest[$key] = ['page' => $row['page'], 'user' => $row['user'], 'time' => $time]; 132 } 133 } 134 135 $imported = 0; 136 $failed = 0; 137 foreach ($latest as $entry) { 138 $this->info(sprintf( 139 'ireadit record: %s by %s at %s', 140 $entry['page'], 141 $entry['user'], 142 dformat($entry['time']) 143 )); 144 145 if ($dryRun) { 146 $imported++; 147 continue; 148 } 149 150 // a single bad record should not abort the whole import 151 try { 152 $this->helper->importAcknowledgement($entry['page'], $entry['user'], $entry['time']); 153 $imported++; 154 } catch (\Exception $e) { 155 $failed++; 156 $this->error(sprintf( 157 'Failed to import read record for %s by %s: %s', 158 $entry['page'], 159 $entry['user'], 160 $e->getMessage() 161 )); 162 } 163 } 164 165 if ($failed) { 166 $this->warning(sprintf('%d read record(s) could not be imported', $failed)); 167 } 168 169 return $imported; 170 } 171 172 /** 173 * Create assignment patterns for every page containing ~~IREADIT...~~ syntax 174 * 175 * @param bool $dryRun 176 * @return array{0:int,1:int} number of assignee entries and number of patterns created 177 * @throws RuntimeException if the assignment patterns cannot be saved 178 */ 179 protected function importAssignments(bool $dryRun): array 180 { 181 // legacy indexer, starting with Mort 182 $pages = idx_getIndex('page', ''); 183 184 $newPatterns = []; 185 foreach ($pages as $page) { 186 $page = trim($page); 187 if ($page === '') continue; 188 189 $source = rawWiki($page); 190 if (!preg_match('/~~IREADIT(.*?)~~/', $source, $match)) { 191 continue; 192 } 193 194 $assignees = $this->parseIreaditAssignees($match[1]); 195 $newPatterns[$page] = $assignees; 196 $this->info(sprintf('Pattern: %s -> %s', $page, $assignees)); 197 } 198 199 if (!$dryRun && $newPatterns) { 200 // saveAssignmentPatterns() rewrites the whole assignments_patterns table, so we must merge 201 $patterns = $this->helper->getAssignmentPatterns(); 202 foreach ($newPatterns as $newPattern => $assignees) { 203 // when a page already has our pattern, append the imported assignees 204 if (!empty($patterns[$newPattern])) { 205 $assignees = $patterns[$newPattern] . ',' . $assignees; 206 } 207 // clean up combined assignees 208 $entries = array_map('trim', explode(',', $assignees)); 209 $entries = array_filter($entries); 210 $patterns[$newPattern] = implode(',', array_unique($entries)); 211 } 212 try { 213 $this->helper->saveAssignmentPatterns($patterns); 214 } catch (\Exception $e) { 215 throw new \RuntimeException('Failed to save assignment patterns: ' . $e->getMessage(), 0, $e); 216 } 217 } 218 219 $assigneeCount = 0; 220 foreach ($newPatterns as $assignees) { 221 $assigneeCount += count(array_filter(explode(',', $assignees))); 222 } 223 224 return [$assigneeCount, count($newPatterns)]; 225 } 226 227 /** 228 * Parse the ~~IREADIT...~~ syntax into an acknowledge assignee string 229 * 230 * Mirrors the parsing in syntax_plugin_ireadit_ireadit::handle(). Empty syntax means 231 * "everyone" in ireadit, which is mapped to the global default group. 232 * 233 * @param string $inner the text between ~~IREADIT and ~~ 234 * @return string comma-separated list of users and @groups 235 */ 236 protected function parseIreaditAssignees(string $inner): string 237 { 238 global $conf; 239 240 $splits = preg_split('/[\s:]+/', trim($inner), -1, PREG_SPLIT_NO_EMPTY); 241 242 $users = []; 243 $groups = []; 244 foreach ($splits as $split) { 245 if ($split[0] === '@') { 246 $groups[] = $split; 247 } else { 248 $users[] = $split; 249 } 250 } 251 252 // empty ~~IREADIT~~ means everyone, so use default group 253 if (!$users && !$groups) { 254 return '@' . $conf['defaultgroup']; 255 } 256 257 return implode(',', array_merge($users, $groups)); 258 } 259} 260