xref: /plugin/acknowledge/cli.php (revision 9bded093d5c0d7867d6c7968fdc20711e40d0698)
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