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