xref: /plugin/renderrevisions/action/save.php (revision cfdd0e17c60380375741461cad3ab400117f7ca1)
16ba0f847SSzymon Olewniczak<?php
26ba0f847SSzymon Olewniczak
3*cfdd0e17SAnna Dabrowskause dokuwiki\Logger;
46ba0f847SSzymon Olewniczakuse dokuwiki\Extension\ActionPlugin;
56ba0f847SSzymon Olewniczakuse dokuwiki\Extension\Event;
66ba0f847SSzymon Olewniczakuse dokuwiki\Extension\EventHandler;
76ba0f847SSzymon Olewniczakuse dokuwiki\File\PageFile;
86ba0f847SSzymon Olewniczak
96ba0f847SSzymon Olewniczak/**
106ba0f847SSzymon Olewniczak * DokuWiki Plugin renderrevisions (Action Component)
116ba0f847SSzymon Olewniczak *
126ba0f847SSzymon Olewniczak * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
136ba0f847SSzymon Olewniczak * @author Andreas Gohr <dokuwiki@cosmocode.de>
146ba0f847SSzymon Olewniczak */
156ba0f847SSzymon Olewniczakclass action_plugin_renderrevisions_save extends ActionPlugin
166ba0f847SSzymon Olewniczak{
176ba0f847SSzymon Olewniczak    /** @var array list of pages that are processed by the plugin */
186ba0f847SSzymon Olewniczak    protected $pages = [];
196ba0f847SSzymon Olewniczak
206ba0f847SSzymon Olewniczak    /** @var string|null  the current page being saved, used to overwrite the contentchanged check */
216ba0f847SSzymon Olewniczak    protected $current = null;
226ba0f847SSzymon Olewniczak
236ba0f847SSzymon Olewniczak    /** @inheritDoc */
246ba0f847SSzymon Olewniczak    public function register(EventHandler $controller)
256ba0f847SSzymon Olewniczak    {
266ba0f847SSzymon Olewniczak        $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'handleParserCacheUse');
276ba0f847SSzymon Olewniczak
286ba0f847SSzymon Olewniczak        $controller->register_hook(
296ba0f847SSzymon Olewniczak            'RENDERER_CONTENT_POSTPROCESS',
306ba0f847SSzymon Olewniczak            'AFTER',
316ba0f847SSzymon Olewniczak            $this,
326ba0f847SSzymon Olewniczak            'handleRenderContent',
336ba0f847SSzymon Olewniczak            null,
346ba0f847SSzymon Olewniczak            PHP_INT_MAX // other plugins might want to change the content before we see it
356ba0f847SSzymon Olewniczak        );
366ba0f847SSzymon Olewniczak
376ba0f847SSzymon Olewniczak        $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handleCommonWikipageSave');
386ba0f847SSzymon Olewniczak    }
396ba0f847SSzymon Olewniczak
406ba0f847SSzymon Olewniczak    /**
416ba0f847SSzymon Olewniczak     * Event handler for PARSER_CACHE_USE
426ba0f847SSzymon Olewniczak     *
436ba0f847SSzymon Olewniczak     * @see https://www.dokuwiki.org/devel:event:PARSER_CACHE_USE
446ba0f847SSzymon Olewniczak     * @param Event $event Event object
456ba0f847SSzymon Olewniczak     * @param mixed $param optional parameter passed when event was registered
466ba0f847SSzymon Olewniczak     * @return void
476ba0f847SSzymon Olewniczak     */
486ba0f847SSzymon Olewniczak    public function handleParserCacheUse(Event $event, $param)
496ba0f847SSzymon Olewniczak    {
506ba0f847SSzymon Olewniczak        $cacheObject = $event->data;
516ba0f847SSzymon Olewniczak
526ba0f847SSzymon Olewniczak        if (!$cacheObject->page) return;
536ba0f847SSzymon Olewniczak        if ($cacheObject->mode !== 'xhtml') return;
546ba0f847SSzymon Olewniczak
556ba0f847SSzymon Olewniczak        // only process pages that match both the skip and match regex
566ba0f847SSzymon Olewniczak
576ba0f847SSzymon Olewniczak        $page = $cacheObject->page;
586ba0f847SSzymon Olewniczak        try {
596ba0f847SSzymon Olewniczak            [$skipRE, $matchRE] = $this->getRegexps();
606ba0f847SSzymon Olewniczak        } catch (\Exception $e) {
616ba0f847SSzymon Olewniczak            msg(hsc($e->getMessage()), -1);
626ba0f847SSzymon Olewniczak            return;
636ba0f847SSzymon Olewniczak        }
646ba0f847SSzymon Olewniczak        if (
656ba0f847SSzymon Olewniczak            ($skipRE && preg_match($skipRE, ":$page")) ||
666ba0f847SSzymon Olewniczak            ($matchRE && !preg_match($matchRE, ":$page"))
676ba0f847SSzymon Olewniczak        ) {
686ba0f847SSzymon Olewniczak            return;
696ba0f847SSzymon Olewniczak        }
706ba0f847SSzymon Olewniczak
716ba0f847SSzymon Olewniczak        // remember that this page was processed
726ba0f847SSzymon Olewniczak        // This is a somewhat ugly workaround for when text snippets are rendered within the same page.
736ba0f847SSzymon Olewniczak        // Those snippets will not have a page context set during cache use event and thus not be processed
746ba0f847SSzymon Olewniczak        // later on in the RENDERER_CONTENT_POSTPROCESS event
756ba0f847SSzymon Olewniczak        $this->pages[$page] = true;
766ba0f847SSzymon Olewniczak    }
776ba0f847SSzymon Olewniczak
786ba0f847SSzymon Olewniczak
796ba0f847SSzymon Olewniczak    /**
806ba0f847SSzymon Olewniczak     * Event handler for RENDERER_CONTENT_POSTPROCESS
816ba0f847SSzymon Olewniczak     *
826ba0f847SSzymon Olewniczak     * @see https://www.dokuwiki.org/devel:event:RENDERER_CONTENT_POSTPROCESS
836ba0f847SSzymon Olewniczak     * @param Event $event Event object
846ba0f847SSzymon Olewniczak     * @param mixed $param optional parameter passed when event was registered
856ba0f847SSzymon Olewniczak     * @return void
866ba0f847SSzymon Olewniczak     */
876ba0f847SSzymon Olewniczak    public function handleRenderContent(Event $event, $param)
886ba0f847SSzymon Olewniczak    {
896ba0f847SSzymon Olewniczak        [$format, $xhtml] = $event->data;
906ba0f847SSzymon Olewniczak        if ($format !== 'xhtml') return;
916ba0f847SSzymon Olewniczak
926ba0f847SSzymon Olewniczak        // thanks to the $this->pages property we might be able to skip some of those checks, but they don't hurt
936ba0f847SSzymon Olewniczak        global $ACT;
946ba0f847SSzymon Olewniczak        global $REV;
956ba0f847SSzymon Olewniczak        global $DATE_AT;
966ba0f847SSzymon Olewniczak        global $ID;
976ba0f847SSzymon Olewniczak        global $INFO;
986ba0f847SSzymon Olewniczak        if ($ACT !== 'show') return;
996ba0f847SSzymon Olewniczak        if ($REV) return;
1006ba0f847SSzymon Olewniczak        if ($DATE_AT) return;
1016ba0f847SSzymon Olewniczak        if (!$INFO['exists']) return;
1026ba0f847SSzymon Olewniczak        if (!$ID) return;
1036ba0f847SSzymon Olewniczak        if (!isset($this->pages[$ID])) return;
1046ba0f847SSzymon Olewniczak
1056ba0f847SSzymon Olewniczak        // all the above still does not ensure we skip sub renderings, so this is our last resort
1066ba0f847SSzymon Olewniczak        if (count(array_filter(debug_backtrace(), fn($t) => $t['function'] === 'p_render')) > 1) return;
1076ba0f847SSzymon Olewniczak
1086ba0f847SSzymon Olewniczak        $md5cache = getCacheName($ID, '.renderrevision');
109ed65e3b8SAndreas Gohr        $md5xhtml = $this->getContentHash($xhtml);
1106ba0f847SSzymon Olewniczak
111*cfdd0e17SAnna Dabrowska        // depending on config, load storage helper
112*cfdd0e17SAnna Dabrowska        /** @var helper_plugin_renderrevisions_storage $storage */
113*cfdd0e17SAnna Dabrowska        $storage = $this->getConf('store') ? plugin_load('helper', 'renderrevisions_storage') : null;
114*cfdd0e17SAnna Dabrowska
1156ba0f847SSzymon Olewniczak        // no or outdated MD5 cache, create new one
1166ba0f847SSzymon Olewniczak        // this means a new revision of the page has been created naturally
1176ba0f847SSzymon Olewniczak        // we store the new render result and are done
1186ba0f847SSzymon Olewniczak        if (!file_exists($md5cache) || filemtime(wikiFN($ID)) > filemtime($md5cache)) {
119ed65e3b8SAndreas Gohr            file_put_contents($md5cache, $md5xhtml);
120*cfdd0e17SAnna Dabrowska            $this->logDebug($ID . ' Wrote render hash cache: path=' . $md5cache . ' hash=' . $md5xhtml);
1216ba0f847SSzymon Olewniczak
122*cfdd0e17SAnna Dabrowska            if ($storage) {
1236ba0f847SSzymon Olewniczak                $storage->saveRevision($ID, filemtime(wikiFN($ID)), $xhtml);
124*cfdd0e17SAnna Dabrowska                $this->logDebug(
125*cfdd0e17SAnna Dabrowska                    $ID . ' Found new revision and stored render for rev=' . filemtime(wikiFN($ID)) .
126*cfdd0e17SAnna Dabrowska                    ' in ' . $storage->getFilename($ID, filemtime(wikiFN($ID)))
127*cfdd0e17SAnna Dabrowska                );
128*cfdd0e17SAnna Dabrowska
1296ba0f847SSzymon Olewniczak                $storage->cleanUp($ID);
1306ba0f847SSzymon Olewniczak            }
1316ba0f847SSzymon Olewniczak
1326ba0f847SSzymon Olewniczak            return;
1336ba0f847SSzymon Olewniczak        }
1346ba0f847SSzymon Olewniczak
1356ba0f847SSzymon Olewniczak        // only act on pages that have not been changed very recently
1366ba0f847SSzymon Olewniczak        if (time() - filemtime(wikiFN($ID)) < $this->getConf('maxfrequency')) {
1376ba0f847SSzymon Olewniczak            return;
1386ba0f847SSzymon Olewniczak        }
1396ba0f847SSzymon Olewniczak
1406ba0f847SSzymon Olewniczak        // get the render result as it were when the page was last changed
1416ba0f847SSzymon Olewniczak        $oldMd5 = file_get_contents($md5cache);
1426ba0f847SSzymon Olewniczak
1436ba0f847SSzymon Olewniczak        // did the rendered content change?
144ed65e3b8SAndreas Gohr        if ($oldMd5 === $md5xhtml) {
1456ba0f847SSzymon Olewniczak            return;
1466ba0f847SSzymon Olewniczak        }
1476ba0f847SSzymon Olewniczak
1486ba0f847SSzymon Olewniczak        // time to create a new revision
149*cfdd0e17SAnna Dabrowska        $oldPageMtime = filemtime(wikiFN($ID));
1506ba0f847SSzymon Olewniczak        $this->current = $ID;
1516ba0f847SSzymon Olewniczak        (new PageFile($ID))->saveWikiText(rawWiki($ID), $this->getLang('summary'));
152*cfdd0e17SAnna Dabrowska
153*cfdd0e17SAnna Dabrowska        $this->logDebug($ID . ' Created new wiki revision');
154*cfdd0e17SAnna Dabrowska
155*cfdd0e17SAnna Dabrowska        if ($storage) {
156*cfdd0e17SAnna Dabrowska            $diff = $this->getRenderDiff($storage->getRevision($ID, $oldPageMtime), $xhtml);
157*cfdd0e17SAnna Dabrowska            $this->logDebug(
158*cfdd0e17SAnna Dabrowska                $ID . ' Render diff between stored rev ' . $oldPageMtime .
159*cfdd0e17SAnna Dabrowska                ' (' . $storage->getFilename($ID, $oldPageMtime) . ') vs current:' . "\n" . $diff
160*cfdd0e17SAnna Dabrowska            );
161*cfdd0e17SAnna Dabrowska        } else {
162*cfdd0e17SAnna Dabrowska            $this->logDebug($ID . ' Render diff unavailable: no stored render for rev ' . filemtime(wikiFN($ID)));
163*cfdd0e17SAnna Dabrowska        }
164*cfdd0e17SAnna Dabrowska
165*cfdd0e17SAnna Dabrowska
1666ba0f847SSzymon Olewniczak        $this->current = null;
1676ba0f847SSzymon Olewniczak    }
1686ba0f847SSzymon Olewniczak
1696ba0f847SSzymon Olewniczak
1706ba0f847SSzymon Olewniczak    /**
1716ba0f847SSzymon Olewniczak     * Event handler for COMMON_WIKIPAGE_SAVE
1726ba0f847SSzymon Olewniczak     *
1736ba0f847SSzymon Olewniczak     * Overwrite the contentChanged flag to force a new revision even though the content did not change
1746ba0f847SSzymon Olewniczak     *
1756ba0f847SSzymon Olewniczak     * @see https://www.dokuwiki.org/devel:event:COMMON_WIKIPAGE_SAVE
1766ba0f847SSzymon Olewniczak     * @param Event $event Event object
1776ba0f847SSzymon Olewniczak     * @param mixed $param optional parameter passed when event was registered
1786ba0f847SSzymon Olewniczak     * @return void
1796ba0f847SSzymon Olewniczak     */
1806ba0f847SSzymon Olewniczak    public function handleCommonWikipageSave(Event $event, $param)
1816ba0f847SSzymon Olewniczak    {
1826ba0f847SSzymon Olewniczak        if ($this->current !== $event->data['id']) return;
1836ba0f847SSzymon Olewniczak        $event->data['contentChanged'] = true;
1846ba0f847SSzymon Olewniczak    }
1856ba0f847SSzymon Olewniczak
1866ba0f847SSzymon Olewniczak
1876ba0f847SSzymon Olewniczak    /**
1886ba0f847SSzymon Olewniczak     * Read the skip and match regex from the config
1896ba0f847SSzymon Olewniczak     *
1906ba0f847SSzymon Olewniczak     * Ensures the regular expressions are valid
1916ba0f847SSzymon Olewniczak     *
1926ba0f847SSzymon Olewniczak     * @return string[] [$skipRE, $matchRE]
1936ba0f847SSzymon Olewniczak     * @throws \Exception if the regular expressions are invalid
1946ba0f847SSzymon Olewniczak     */
1956ba0f847SSzymon Olewniczak    public function getRegexps()
1966ba0f847SSzymon Olewniczak    {
1976ba0f847SSzymon Olewniczak        $skip = $this->getConf('skipRegex');
1986ba0f847SSzymon Olewniczak        $skipRE = '';
1996ba0f847SSzymon Olewniczak        $match = $this->getConf('matchRegex');
2006ba0f847SSzymon Olewniczak        $matchRE = '';
2016ba0f847SSzymon Olewniczak
2026ba0f847SSzymon Olewniczak        if ($skip) {
2036ba0f847SSzymon Olewniczak            $skipRE = '/' . $skip . '/';
2046ba0f847SSzymon Olewniczak            if (@preg_match($skipRE, '') === false) {
2056ba0f847SSzymon Olewniczak                throw new \Exception('Invalid regular expression in $conf[\'skipRegex\']. ' . preg_last_error_msg());
2066ba0f847SSzymon Olewniczak            }
2076ba0f847SSzymon Olewniczak        }
2086ba0f847SSzymon Olewniczak
2096ba0f847SSzymon Olewniczak        if ($match) {
2106ba0f847SSzymon Olewniczak            $matchRE = '/' . $match . '/';
2116ba0f847SSzymon Olewniczak            if (@preg_match($matchRE, '') === false) {
2126ba0f847SSzymon Olewniczak                throw new \Exception('Invalid regular expression in $conf[\'matchRegex\']. ' . preg_last_error_msg());
2136ba0f847SSzymon Olewniczak            }
2146ba0f847SSzymon Olewniczak        }
2156ba0f847SSzymon Olewniczak        return [$skipRE, $matchRE];
2166ba0f847SSzymon Olewniczak    }
217ed65e3b8SAndreas Gohr
218ed65e3b8SAndreas Gohr    /**
219ed65e3b8SAndreas Gohr     * Get the hash for the given content
220ed65e3b8SAndreas Gohr     *
221ed65e3b8SAndreas Gohr     * Strips all whitespace and HTML tags to ensure only real content changes are detected
222ed65e3b8SAndreas Gohr     *
223ed65e3b8SAndreas Gohr     * @param string $xhtml
224ed65e3b8SAndreas Gohr     */
225*cfdd0e17SAnna Dabrowska    protected function getContentHash(string $xhtml): string
226ed65e3b8SAndreas Gohr    {
227ed65e3b8SAndreas Gohr        return md5(preg_replace('/\s+/', '', strip_tags($xhtml)));
228ed65e3b8SAndreas Gohr    }
229*cfdd0e17SAnna Dabrowska
230*cfdd0e17SAnna Dabrowska    /**
231*cfdd0e17SAnna Dabrowska     * Log a message when debug logging is enabled
232*cfdd0e17SAnna Dabrowska     *
233*cfdd0e17SAnna Dabrowska     * @param string $message
234*cfdd0e17SAnna Dabrowska     * @return void
235*cfdd0e17SAnna Dabrowska     */
236*cfdd0e17SAnna Dabrowska    protected function logDebug(string $message)
237*cfdd0e17SAnna Dabrowska    {
238*cfdd0e17SAnna Dabrowska        if (!$this->getConf('debug')) return;
239*cfdd0e17SAnna Dabrowska        $logger = Logger::getInstance('renderrevisions');
240*cfdd0e17SAnna Dabrowska        $logger->log($message);
241*cfdd0e17SAnna Dabrowska    }
242*cfdd0e17SAnna Dabrowska
243*cfdd0e17SAnna Dabrowska    /**
244*cfdd0e17SAnna Dabrowska     * Build a render diff for logging purposes
245*cfdd0e17SAnna Dabrowska     *
246*cfdd0e17SAnna Dabrowska     * @param string $before
247*cfdd0e17SAnna Dabrowska     * @param string $after
248*cfdd0e17SAnna Dabrowska     * @return string
249*cfdd0e17SAnna Dabrowska     */
250*cfdd0e17SAnna Dabrowska    protected function getRenderDiff(string $before, string $after): string
251*cfdd0e17SAnna Dabrowska    {
252*cfdd0e17SAnna Dabrowska        if ($before === $after) return 'No render diff (content identical).';
253*cfdd0e17SAnna Dabrowska
254*cfdd0e17SAnna Dabrowska        $Difference = new \Diff(
255*cfdd0e17SAnna Dabrowska            explode("\n", $before),
256*cfdd0e17SAnna Dabrowska            explode("\n", $after)
257*cfdd0e17SAnna Dabrowska        );
258*cfdd0e17SAnna Dabrowska
259*cfdd0e17SAnna Dabrowska        $DiffFormatter = new UnifiedDiffFormatter();
260*cfdd0e17SAnna Dabrowska        return $DiffFormatter->format($Difference);
261*cfdd0e17SAnna Dabrowska    }
2626ba0f847SSzymon Olewniczak}
263