1<?php
2if (!defined('DOKU_INC')) die();
3
4class action_plugin_fuzzysearch extends DokuWiki_Action_Plugin {
5    private function getCacheFile() {
6        $user = $this->getCurrentUser();
7        if (!$user) return null;
8        $userHash = md5($user);
9        return DOKU_INC . 'data/cache/fuzzysearch_pages_' . $userHash . '.json';
10    }
11
12    private function getCacheMetaFile() {
13        $user = $this->getCurrentUser();
14        if (!$user) return null;
15        $userHash = md5($user);
16        return DOKU_INC . 'data/cache/fuzzysearch_pages_' . $userHash . '.meta.json';
17    }
18
19    public function register(Doku_Event_Handler $controller) {
20        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handle_ajax_call');
21        $controller->register_hook('INDEXER_VERSION_GET', 'BEFORE', $this, 'update_cache_on_change');
22        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'load_scripts');
23    }
24
25    public function handle_ajax_call(Doku_Event &$event, $param) {
26        if ($event->data === 'fuzzysearch_pages') {
27            $event->preventDefault();
28            $event->stopPropagation();
29
30            if (!$this->isLoggedIn()) {
31                $this->redirectToLogin();
32                exit;
33            }
34
35            $cacheFile = $this->getCacheFile();
36            $this->ensureCacheExists();
37
38            if (file_exists($cacheFile)) {
39                header('Content-Type: application/json');
40                readfile($cacheFile);
41            } else {
42                header('HTTP/1.1 500 Internal Server Error');
43                echo json_encode(['error' => 'Cache generation failed']);
44            }
45            exit;
46        }
47    }
48
49    public function update_cache_on_change(Doku_Event &$event, $param) {
50        if ($this->isLoggedIn()) {
51            $this->ensureCacheExists(true);
52        }
53    }
54
55    public function load_scripts(Doku_Event &$event, $param) {
56        error_log('FuzzySearch: Loading scripts');
57        // Load Fuse.js
58        $fuseSrc = DOKU_BASE . 'lib/plugins/fuzzysearch/fuse.min.js';
59        if (!in_array($fuseSrc, array_column($event->data['script'], 'src'))) {
60            $event->data['script'][] = [
61                'type' => 'text/javascript',
62                'src' => $fuseSrc,
63                '_data' => '',
64                'defer' => 'defer'
65            ];
66            error_log('FuzzySearch: Fuse.js added');
67        }
68
69        // Load search bar script
70        $scriptSrc = DOKU_BASE . 'lib/plugins/fuzzysearch/script.js';
71        if (!in_array($scriptSrc, array_column($event->data['script'], 'src'))) {
72            $event->data['script'][] = [
73                'type' => 'text/javascript',
74                'src' => $scriptSrc,
75                '_data' => '',
76                'defer' => 'defer'
77            ];
78            error_log('FuzzySearch: script.js added');
79        }
80
81        // Load editor enhancement script
82        $editorSrc = DOKU_BASE . 'lib/plugins/fuzzysearch/editor.js';
83        if (!in_array($editorSrc, array_column($event->data['script'], 'src'))) {
84            $event->data['script'][] = [
85                'type' => 'text/javascript',
86                'src' => $editorSrc,
87                '_data' => '',
88                'defer' => 'defer'
89            ];
90            error_log('FuzzySearch: editor.js added');
91        }
92    }
93
94    private function ensureCacheExists($forceUpdate = false) {
95        $cacheFile = $this->getCacheFile();
96        $cacheMetaFile = $this->getCacheMetaFile();
97        if (!$cacheFile || !$cacheMetaFile) return;
98
99        $meta = $this->loadCacheMeta($cacheMetaFile);
100        $lastModified = $this->getLastPageModificationTime();
101
102        if (!$forceUpdate && file_exists($cacheFile) && isset($meta['last_updated']) && $meta['last_updated'] >= $lastModified) {
103            return;
104        }
105
106        $pages = $this->generatePageList();
107        $jsonData = json_encode($pages);
108        $metaData = ['last_updated' => time()];
109
110        if (!is_dir(dirname($cacheFile))) {
111            mkdir(dirname($cacheFile), 0755, true);
112        }
113
114        file_put_contents($cacheFile, $jsonData);
115        file_put_contents($cacheMetaFile, json_encode($metaData));
116    }
117
118    private function generatePageList() {
119        if (!$this->isLoggedIn()) {
120            $this->redirectToLogin();
121            exit;
122        }
123
124        $dir = DOKU_INC . 'data/pages/';
125        $page_list = $this->getPageList($dir);
126        $pages = [];
127        foreach ($page_list as $file) {
128            $id = pathID($file);
129            if (auth_quickaclcheck($id) >= AUTH_READ) {
130                $title = p_get_first_heading($id) ?: noNS($id);
131                $pages[] = ['id' => $id, 'title' => $title];
132            }
133        }
134        return $pages;
135    }
136
137    private function getPageList($dir, $base = '') {
138        $files = [];
139        $items = dir($dir);
140        while (false !== ($entry = $items->read())) {
141            if ($entry === '.' || $entry === '..') continue;
142            $path = $dir . $entry;
143            if (is_dir($path) && auth_quickaclcheck($base . $entry . ':') >= AUTH_READ) {
144                $files = array_merge($files, $this->getPageList($path . '/', $base . $entry . ':'));
145            } elseif (preg_match('/\.txt$/', $entry)) {
146                $files[] = $base . substr($entry, 0, -4);
147            }
148        }
149        $items->close();
150        return $files;
151    }
152
153    private function getLastPageModificationTime() {
154        $dir = DOKU_INC . 'data/pages/';
155        $latest = 0;
156        $this->scanDirForLatest($dir, $latest);
157        return $latest;
158    }
159
160    private function scanDirForLatest($dir, &$latest) {
161        $items = dir($dir);
162        while (false !== ($entry = $items->read())) {
163            if ($entry === '.' || $entry === '..') continue;
164            $path = $dir . $entry;
165            if (is_dir($path)) {
166                $this->scanDirForLatest($path . '/', $latest);
167            } elseif (preg_match('/\.txt$/', $entry)) {
168                $mtime = filemtime($path);
169                if ($mtime > $latest) $latest = $mtime;
170            }
171        }
172        $items->close();
173    }
174
175    private function loadCacheMeta($file) {
176        if (file_exists($file)) {
177            return json_decode(file_get_contents($file), true) ?: [];
178        }
179        return [];
180    }
181
182    private function isLoggedIn() {
183        return !empty($_SERVER['REMOTE_USER']);
184    }
185
186    private function getCurrentUser() {
187        return $_SERVER['REMOTE_USER'] ?? null;
188    }
189
190    private function redirectToLogin() {
191        global $ID;
192        $loginUrl = wl('', ['do' => 'login', 'id' => $ID], true, '&');
193        header("Location: $loginUrl");
194    }
195}