xref: /plugin/renderrevisions/action/save.php (revision cfdd0e17c60380375741461cad3ab400117f7ca1)
1<?php
2
3use dokuwiki\Logger;
4use dokuwiki\Extension\ActionPlugin;
5use dokuwiki\Extension\Event;
6use dokuwiki\Extension\EventHandler;
7use dokuwiki\File\PageFile;
8
9/**
10 * DokuWiki Plugin renderrevisions (Action Component)
11 *
12 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
13 * @author Andreas Gohr <dokuwiki@cosmocode.de>
14 */
15class action_plugin_renderrevisions_save extends ActionPlugin
16{
17    /** @var array list of pages that are processed by the plugin */
18    protected $pages = [];
19
20    /** @var string|null  the current page being saved, used to overwrite the contentchanged check */
21    protected $current = null;
22
23    /** @inheritDoc */
24    public function register(EventHandler $controller)
25    {
26        $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'handleParserCacheUse');
27
28        $controller->register_hook(
29            'RENDERER_CONTENT_POSTPROCESS',
30            'AFTER',
31            $this,
32            'handleRenderContent',
33            null,
34            PHP_INT_MAX // other plugins might want to change the content before we see it
35        );
36
37        $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handleCommonWikipageSave');
38    }
39
40    /**
41     * Event handler for PARSER_CACHE_USE
42     *
43     * @see https://www.dokuwiki.org/devel:event:PARSER_CACHE_USE
44     * @param Event $event Event object
45     * @param mixed $param optional parameter passed when event was registered
46     * @return void
47     */
48    public function handleParserCacheUse(Event $event, $param)
49    {
50        $cacheObject = $event->data;
51
52        if (!$cacheObject->page) return;
53        if ($cacheObject->mode !== 'xhtml') return;
54
55        // only process pages that match both the skip and match regex
56
57        $page = $cacheObject->page;
58        try {
59            [$skipRE, $matchRE] = $this->getRegexps();
60        } catch (\Exception $e) {
61            msg(hsc($e->getMessage()), -1);
62            return;
63        }
64        if (
65            ($skipRE && preg_match($skipRE, ":$page")) ||
66            ($matchRE && !preg_match($matchRE, ":$page"))
67        ) {
68            return;
69        }
70
71        // remember that this page was processed
72        // This is a somewhat ugly workaround for when text snippets are rendered within the same page.
73        // Those snippets will not have a page context set during cache use event and thus not be processed
74        // later on in the RENDERER_CONTENT_POSTPROCESS event
75        $this->pages[$page] = true;
76    }
77
78
79    /**
80     * Event handler for RENDERER_CONTENT_POSTPROCESS
81     *
82     * @see https://www.dokuwiki.org/devel:event:RENDERER_CONTENT_POSTPROCESS
83     * @param Event $event Event object
84     * @param mixed $param optional parameter passed when event was registered
85     * @return void
86     */
87    public function handleRenderContent(Event $event, $param)
88    {
89        [$format, $xhtml] = $event->data;
90        if ($format !== 'xhtml') return;
91
92        // thanks to the $this->pages property we might be able to skip some of those checks, but they don't hurt
93        global $ACT;
94        global $REV;
95        global $DATE_AT;
96        global $ID;
97        global $INFO;
98        if ($ACT !== 'show') return;
99        if ($REV) return;
100        if ($DATE_AT) return;
101        if (!$INFO['exists']) return;
102        if (!$ID) return;
103        if (!isset($this->pages[$ID])) return;
104
105        // all the above still does not ensure we skip sub renderings, so this is our last resort
106        if (count(array_filter(debug_backtrace(), fn($t) => $t['function'] === 'p_render')) > 1) return;
107
108        $md5cache = getCacheName($ID, '.renderrevision');
109        $md5xhtml = $this->getContentHash($xhtml);
110
111        // depending on config, load storage helper
112        /** @var helper_plugin_renderrevisions_storage $storage */
113        $storage = $this->getConf('store') ? plugin_load('helper', 'renderrevisions_storage') : null;
114
115        // no or outdated MD5 cache, create new one
116        // this means a new revision of the page has been created naturally
117        // we store the new render result and are done
118        if (!file_exists($md5cache) || filemtime(wikiFN($ID)) > filemtime($md5cache)) {
119            file_put_contents($md5cache, $md5xhtml);
120            $this->logDebug($ID . ' Wrote render hash cache: path=' . $md5cache . ' hash=' . $md5xhtml);
121
122            if ($storage) {
123                $storage->saveRevision($ID, filemtime(wikiFN($ID)), $xhtml);
124                $this->logDebug(
125                    $ID . ' Found new revision and stored render for rev=' . filemtime(wikiFN($ID)) .
126                    ' in ' . $storage->getFilename($ID, filemtime(wikiFN($ID)))
127                );
128
129                $storage->cleanUp($ID);
130            }
131
132            return;
133        }
134
135        // only act on pages that have not been changed very recently
136        if (time() - filemtime(wikiFN($ID)) < $this->getConf('maxfrequency')) {
137            return;
138        }
139
140        // get the render result as it were when the page was last changed
141        $oldMd5 = file_get_contents($md5cache);
142
143        // did the rendered content change?
144        if ($oldMd5 === $md5xhtml) {
145            return;
146        }
147
148        // time to create a new revision
149        $oldPageMtime = filemtime(wikiFN($ID));
150        $this->current = $ID;
151        (new PageFile($ID))->saveWikiText(rawWiki($ID), $this->getLang('summary'));
152
153        $this->logDebug($ID . ' Created new wiki revision');
154
155        if ($storage) {
156            $diff = $this->getRenderDiff($storage->getRevision($ID, $oldPageMtime), $xhtml);
157            $this->logDebug(
158                $ID . ' Render diff between stored rev ' . $oldPageMtime .
159                ' (' . $storage->getFilename($ID, $oldPageMtime) . ') vs current:' . "\n" . $diff
160            );
161        } else {
162            $this->logDebug($ID . ' Render diff unavailable: no stored render for rev ' . filemtime(wikiFN($ID)));
163        }
164
165
166        $this->current = null;
167    }
168
169
170    /**
171     * Event handler for COMMON_WIKIPAGE_SAVE
172     *
173     * Overwrite the contentChanged flag to force a new revision even though the content did not change
174     *
175     * @see https://www.dokuwiki.org/devel:event:COMMON_WIKIPAGE_SAVE
176     * @param Event $event Event object
177     * @param mixed $param optional parameter passed when event was registered
178     * @return void
179     */
180    public function handleCommonWikipageSave(Event $event, $param)
181    {
182        if ($this->current !== $event->data['id']) return;
183        $event->data['contentChanged'] = true;
184    }
185
186
187    /**
188     * Read the skip and match regex from the config
189     *
190     * Ensures the regular expressions are valid
191     *
192     * @return string[] [$skipRE, $matchRE]
193     * @throws \Exception if the regular expressions are invalid
194     */
195    public function getRegexps()
196    {
197        $skip = $this->getConf('skipRegex');
198        $skipRE = '';
199        $match = $this->getConf('matchRegex');
200        $matchRE = '';
201
202        if ($skip) {
203            $skipRE = '/' . $skip . '/';
204            if (@preg_match($skipRE, '') === false) {
205                throw new \Exception('Invalid regular expression in $conf[\'skipRegex\']. ' . preg_last_error_msg());
206            }
207        }
208
209        if ($match) {
210            $matchRE = '/' . $match . '/';
211            if (@preg_match($matchRE, '') === false) {
212                throw new \Exception('Invalid regular expression in $conf[\'matchRegex\']. ' . preg_last_error_msg());
213            }
214        }
215        return [$skipRE, $matchRE];
216    }
217
218    /**
219     * Get the hash for the given content
220     *
221     * Strips all whitespace and HTML tags to ensure only real content changes are detected
222     *
223     * @param string $xhtml
224     */
225    protected function getContentHash(string $xhtml): string
226    {
227        return md5(preg_replace('/\s+/', '', strip_tags($xhtml)));
228    }
229
230    /**
231     * Log a message when debug logging is enabled
232     *
233     * @param string $message
234     * @return void
235     */
236    protected function logDebug(string $message)
237    {
238        if (!$this->getConf('debug')) return;
239        $logger = Logger::getInstance('renderrevisions');
240        $logger->log($message);
241    }
242
243    /**
244     * Build a render diff for logging purposes
245     *
246     * @param string $before
247     * @param string $after
248     * @return string
249     */
250    protected function getRenderDiff(string $before, string $after): string
251    {
252        if ($before === $after) return 'No render diff (content identical).';
253
254        $Difference = new \Diff(
255            explode("\n", $before),
256            explode("\n", $after)
257        );
258
259        $DiffFormatter = new UnifiedDiffFormatter();
260        return $DiffFormatter->format($Difference);
261    }
262}
263