1<?php
2
3
4namespace ComboStrap;
5
6use dokuwiki\Cache\CacheParser;
7use dokuwiki\Cache\CacheRenderer;
8use splitbrain\slika\Exception;
9
10/**
11 *
12 * @package ComboStrap
13 *
14 * Manage the cache dependencies for a slot level (not instructions cache).
15 *
16 * The dependencies are stored on a file system.
17 *
18 * Cache dependencies are used:
19 *   * to generate the cache key output
20 *   * to add cache validity dependency such as requested page,
21 *
22 * For cache key generation, this is mostly used on
23 *   * side slots to have several output of a list {@link \syntax_plugin_combo_pageexplorer navigation pane} for different namespace (ie there is one cache by namespace)
24 *   * header and footer main slot to have one output for each requested main page
25 */
26class MarkupCacheDependencies
27{
28    /**
29     * The dependency value is the requested page path
30     * (used for syntax mostly used in the header and footer of the main slot for instance)
31     */
32    public const REQUESTED_PAGE_DEPENDENCY = "requested_page";
33    /**
34     * The special scope value current means the namespace of the requested page
35     * The real scope value is then calculated before retrieving the cache
36     */
37    public const REQUESTED_NAMESPACE_DEPENDENCY = "requested_namespace";
38    /**
39     * @deprecated use the {@link MarkupCacheDependencies::REQUESTED_NAMESPACE_DEPENDENCY}
40     */
41    public const NAMESPACE_OLD_VALUE = "current";
42
43    /**
44     * This dependencies have an impact on the
45     * output location of the cache
46     * {@link MarkupCacheDependencies::getOrCalculateDependencyKey()}
47     */
48    public const OUTPUT_DEPENDENCIES = [self::REQUESTED_PAGE_DEPENDENCY, self::REQUESTED_NAMESPACE_DEPENDENCY];
49
50    /**
51     * This dependencies have an impact on the freshness
52     * of the cache
53     */
54    public const validityDependencies = [
55        self::BACKLINKS_DEPENDENCY,
56        self::SQL_DEPENDENCY,
57        self::PAGE_PRIMARY_META_DEPENDENCY,
58        self::PAGE_SYSTEM_DEPENDENCY
59    ];
60
61    /**
62     * Backlinks are printed in the page
63     * {@link \action_plugin_combo_backlinkmutation}
64     * If a referent page add or delete a link,
65     * the slot should be refreshed / cache should be deleted
66     */
67    const BACKLINKS_DEPENDENCY = "backlinks";
68
69    /**
70     * A page sql is in the page
71     * (The page should be refreshed by default once a day)
72     */
73    const SQL_DEPENDENCY = "sql";
74
75    /**
76     * If the name, the title, the h1 or the description
77     * of a page changes, the cache should be invalidated
78     * See {@link \action_plugin_combo_pageprimarymetamutation}
79     */
80    const PAGE_PRIMARY_META_DEPENDENCY = "page_primary_meta";
81
82    /**
83     * If a page is added or deleted
84     * See {@link \action_plugin_combo_pagesystemmutation}
85     */
86    const PAGE_SYSTEM_DEPENDENCY = "page_system";
87
88    const CANONICAL = "cache:dependency";
89
90
91    /**
92     * @var CacheParser
93     */
94    private $dependenciesCacheStore;
95
96
97    /**
98     * @var array list of dependencies to calculate the cache key
99     *
100     * In a general pattern, a dependency is a series of function that would output runtime data
101     * that should go into the render cache key such as user logged in, requested page, namespace of the requested page, ...
102     *
103     * The cache dependencies data are saved alongside the page (same as snippets)
104     *
105     */
106    private $runtimeAddedDependencies = null;
107    /**
108     * The stored runtime dependencies
109     * @var array
110     */
111    private $runtimeStoreDependencies;
112
113
114    /**
115     * @var string the first key captured
116     */
117    private $firstActualKey;
118    private FetcherMarkup $markupFetcher;
119
120
121    /**
122     * CacheManagerForSlot constructor.
123     *
124     */
125    private function __construct(FetcherMarkup $markupFetcher)
126    {
127
128        $this->markupFetcher = $markupFetcher;
129        $executingPath = $markupFetcher->getExecutingPathOrNull();
130        if ($executingPath !== null) {
131            $data = $this->getDependenciesCacheStore()->retrieveCache();
132            if (!empty($data)) {
133                $this->runtimeStoreDependencies = json_decode($data, true);
134            }
135        }
136
137    }
138
139    public static function create(FetcherMarkup $fetcherMarkup): MarkupCacheDependencies
140    {
141        return new MarkupCacheDependencies($fetcherMarkup);
142    }
143
144    /**
145     * Rerender for now only the secondary slot if it has cache dependency
146     * (ie {@link MarkupCacheDependencies::PAGE_SYSTEM_DEPENDENCY} or {@link MarkupCacheDependencies::PAGE_PRIMARY_META_DEPENDENCY})
147     * @param $pathAddedOrDeleted
148     * @param string $dependency -  a {@link MarkupCacheDependencies} ie
149     * @param string $event
150     */
151    public static function reRenderSideSlotIfNeeded($pathAddedOrDeleted, string $dependency, string $event)
152    {
153
154        /**
155         * Rerender secondary slot if needed
156         */
157        $page = MarkupPath::createMarkupFromStringPath($pathAddedOrDeleted);
158        $pageWikiPath = $page->getPathObject();
159        if (!($pageWikiPath instanceof WikiPath)) {
160            LogUtility::errorIfDevOrTest("The path should be a wiki path");
161            return;
162        }
163        $slots = [$page->getSideSlot()];
164        foreach ($slots as $slot) {
165            if ($slot === null) {
166                continue;
167            }
168            try {
169                $slotFetcher = FetcherMarkup::confRoot()
170                    ->setRequestedMimeToXhtml()
171                    ->setRequestedContextPath($pageWikiPath)
172                    ->setRequestedExecutingPath($slot)
173                    ->build();
174            } catch (ExceptionNotExists $e) {
175                // layout fragment does not exists
176                continue;
177            }
178            $cacheDependencies = $slotFetcher->getOutputCacheDependencies();
179            if ($cacheDependencies->hasDependency($dependency)) {
180                $link = PluginUtility::getDocumentationHyperLink("cache:slot", "Slot Dependency", false);
181                $message = "$link ($dependency) was met with the primary slot ($pathAddedOrDeleted).";
182                CacheLog::deleteCacheIfExistsAndLog(
183                    $slotFetcher,
184                    $event,
185                    $message
186                );
187                CacheLog::renderCacheAndLog(
188                    $slotFetcher,
189                    $event,
190                    $message
191                );
192            }
193
194        }
195
196    }
197
198
199    /**
200     * @return string - output the namespace used in the cache key
201     *
202     * For example:
203     *   * the ':sidebar' html output may be dependent to the namespace `ns` or `ns2`
204     */
205    public function getValueForKey($dependenciesValue): string
206    {
207
208        /**
209         * Set the logical id
210         * When no $ID is set (for instance, test),
211         * the logical id is the id
212         *
213         * The logical id depends on the namespace attribute of the {@link \syntax_plugin_combo_pageexplorer}
214         * stored in the `scope` metadata.
215         *
216         * Scope is directory/namespace based
217         */
218        $path = $this->markupFetcher->getRequestedContextPath();
219        $requestedPage = MarkupPath::createPageFromPathObject($path);
220        switch ($dependenciesValue) {
221            case MarkupCacheDependencies::NAMESPACE_OLD_VALUE:
222            case MarkupCacheDependencies::REQUESTED_NAMESPACE_DEPENDENCY:
223                try {
224                    $parentPath = $requestedPage->getPathObject()->getParent();
225                    return $parentPath->toAbsoluteId();
226                } catch (ExceptionNotFound $e) {
227                    // root
228                    return ":";
229                }
230            case MarkupCacheDependencies::REQUESTED_PAGE_DEPENDENCY:
231                return $requestedPage->getPathObject()->toAbsoluteId();
232            default:
233                throw new ExceptionRuntimeInternal("The requested dependency value ($dependenciesValue) has no calculation");
234        }
235
236
237    }
238
239    /**
240     * @return string
241     *
242     * Cache is now managed by dependencies function that creates a unique key
243     * for the instruction document and the output document
244     *
245     * See the discussion at: https://github.com/splitbrain/dokuwiki/issues/3496
246     * @throws ExceptionCompile
247     * @var $actualKey
248     */
249    public function getOrCalculateDependencyKey($actualKey): string
250    {
251        /**
252         * We should wrap a call only once
253         * We capture therefore the first actual key passed
254         */
255        if ($this->firstActualKey === null) {
256            $this->firstActualKey = $actualKey;
257        }
258        $dependencyKey = $this->firstActualKey;
259        $runtimeDependencies = $this->getDependencies();
260
261        foreach ($runtimeDependencies as $dependency) {
262            if (in_array($dependency, self::OUTPUT_DEPENDENCIES)) {
263                $dependencyKey .= $this->getValueForKey($dependency);
264            }
265        }
266        return $dependencyKey;
267    }
268
269
270    /**
271     * @param string $dependencyName
272     * @return MarkupCacheDependencies
273     */
274    public function addDependency(string $dependencyName): MarkupCacheDependencies
275    {
276        if (PluginUtility::isDevOrTest()) {
277            if (!in_array($dependencyName, self::OUTPUT_DEPENDENCIES) &&
278                !in_array($dependencyName, self::validityDependencies)
279            ) {
280                throw new ExceptionRuntime("Unknown dependency value ($dependencyName)");
281            }
282        }
283        $this->runtimeAddedDependencies[$dependencyName] = "";
284        return $this;
285    }
286
287    public
288    function getDependencies(): array
289    {
290        if ($this->runtimeAddedDependencies !== null) {
291            return array_keys($this->runtimeAddedDependencies);
292        }
293        if ($this->runtimeStoreDependencies === null) {
294            return [];
295        }
296        return array_keys($this->runtimeStoreDependencies);
297    }
298
299    /**
300     * The default key as seen in {@link CacheParser}
301     * Used for test purpose
302     * @return string
303     */
304    public
305    function getDefaultKey(): string
306    {
307        try {
308            try {
309                // dokuwiki cache key compatible
310                $wikiId = $this->markupFetcher->getRequestedExecutingPath()->toWikiPath()->getWikiId();
311                $absoluteString = wikiFN($wikiId);
312            } catch (ExceptionCast|ExceptionNotFound $e) {
313                $absoluteString = $this->markupFetcher->getRequestedExecutingPath()->toAbsoluteId();
314            }
315            $keyDokuWikiCompliant = str_replace("\\", "/", $absoluteString);
316            return $keyDokuWikiCompliant . $_SERVER['HTTP_HOST'] . $_SERVER['SERVER_PORT'];
317        } catch (ExceptionNotFound $e) {
318            throw new ExceptionRuntimeInternal("No executing path to calculate the cache key");
319        }
320
321    }
322
323    /**
324     * Snippet.json, Cache dependency are data dependent
325     *
326     * For instance, the carrousel may add glide or grid as snippet. It depends on the the number of backlinks.
327     *
328     * Therefore the output should be unique by rendered fragment
329     * Therefore we reroute (recalculate the cache key to be the same than the html file)
330     *
331     * @param CacheParser $cache
332     * @return void
333     */
334    public
335    function rerouteCacheDestination(CacheParser &$cache)
336    {
337
338        try {
339
340            $cache->key = $this->getOrCalculateDependencyKey($cache->key);
341            $cache->cache = getCacheName($cache->key, '.' . $cache->mode);
342
343        } catch (ExceptionCompile $e) {
344            LogUtility::msg("Error while trying to reroute the content cache destination for the fetcher ({$this->markupFetcher}). You may have cache problem. Error: {$e->getMessage()}");
345        }
346
347    }
348
349
350    /**
351     */
352    public function storeDependencies()
353    {
354
355        /**
356         * Cache file
357         * Using a cache parser, set the page id and will trigger
358         * the parser cache use event in order to log/report the cache usage
359         * At {@link action_plugin_combo_cache::createCacheReport()}
360         */
361        $dependencies = $this->getDependenciesCacheStore();
362        $deps = $this->runtimeAddedDependencies;
363        if ($deps !== null) {
364            $jsonDeps = json_encode($deps);
365            $dependencies->storeCache($jsonDeps);
366        } else {
367            // dependencies does not exist or were removed
368            $dependencies->removeCache();
369        }
370
371
372    }
373
374    public
375    function getDependenciesCacheStore(): CacheParser
376    {
377        if ($this->dependenciesCacheStore !== null) {
378            return $this->dependenciesCacheStore;
379        }
380
381        /**
382         * The local path to calculate the full qualified Os path
383         */
384        try {
385            $executingPath = $this->markupFetcher->getRequestedExecutingPath();
386        } catch (ExceptionNotFound $e) {
387            throw new ExceptionRuntimeInternal("There is no executing path, you can create a cache dependencies store", self::CANONICAL);
388        }
389
390        list($wikiId, $localPath) = FetcherMarkupBuilder::getWikiIdAndLocalFileDokuwikiCompliant($executingPath);
391        $this->dependenciesCacheStore = new CacheParser($wikiId, $localPath, "deps.json");
392        return $this->dependenciesCacheStore;
393    }
394
395    public
396    function hasDependency(string $dependencyName): bool
397    {
398        $dependencies = $this->getDependencies();
399        return in_array($dependencyName, $dependencies);
400    }
401
402}
403