From 19e4b6cc6aaad4edc24b48fff70db8d4fca8da01 Mon Sep 17 00:00:00 2001 From: YangJH <1286091655@qq.com> Date: Thu, 8 Oct 2026 00:13:43 +0800 Subject: [PATCH 1/2] Word highlighter 0.0.5: store settings in the user configuration The extension wrote its settings into its own directory (static/config..json and an executable static/config..js), so it failed with "Your config file is not writable" whenever the extensions folder is read-only for the web server, and generated a JavaScript file at runtime. - Store the word list and options with setUserConfiguration(); no file writes anywhere. Settings from an existing static/config..json are picked up automatically until they are saved once. - Pass the settings to the script through the js_vars hook (context.extensions['Word highlighter'].configuration) instead of a generated script file; keep window.WordHighlighterConf as a fallback. - Fix highlighting that intermittently did not start: the script is loaded async and could run before #stream was parsed; wait for DOMContentLoaded (and for the FreshRSS global context). - Escape the word list in the configure textarea. Co-Authored-By: Claude Opus 5.5 --- xExtension-WordHighlighter/README.md | 9 ++ xExtension-WordHighlighter/configure.phtml | 14 +- xExtension-WordHighlighter/extension.php | 131 ++++++++++-------- xExtension-WordHighlighter/metadata.json | 2 +- xExtension-WordHighlighter/static/.gitignore | 2 + .../static/word-highlighter.js | 77 ++++++++-- 6 files changed, 149 insertions(+), 86 deletions(-) diff --git a/xExtension-WordHighlighter/README.md b/xExtension-WordHighlighter/README.md index fcc7924b..db2a0b79 100644 --- a/xExtension-WordHighlighter/README.md +++ b/xExtension-WordHighlighter/README.md @@ -24,6 +24,15 @@ Light theme: ## Changelog +- 0.0.5 Settings are stored in the user configuration instead of files + written into the extension directory, so the extension works with a + read-only `extensions/` folder (no more "config file is not writable" + error) and no JavaScript file is generated at runtime. Existing + `static/config..json` settings are picked up automatically until + saved once. Settings reach the script through `js_vars`. + Fixed highlighting intermittently not starting: the script is loaded + `async` and could run before `#stream` was parsed; it now waits for + `DOMContentLoaded`. The word list textarea is now escaped. - 0.0.3 Turkish language support added. - 0.0.2 use `json` for storing configuration, add more configuration options (enable_in_article, enable_logs, case_sensitive, separate_word_search) diff --git a/xExtension-WordHighlighter/configure.phtml b/xExtension-WordHighlighter/configure.phtml index ea2be03a..2b6fd23c 100644 --- a/xExtension-WordHighlighter/configure.phtml +++ b/xExtension-WordHighlighter/configure.phtml @@ -12,7 +12,7 @@ declare(strict_types=1);
+ id="words_list">word_highlighter_conf, ENT_QUOTES, 'UTF-8') ?>
@@ -75,14 +75,10 @@ declare(strict_types=1);
- permission_problem !== '') { ?> -

permission_problem) ?>

- -
- - -
- +
+ + +
diff --git a/xExtension-WordHighlighter/extension.php b/xExtension-WordHighlighter/extension.php index 3c56d5d7..1c828b9b 100644 --- a/xExtension-WordHighlighter/extension.php +++ b/xExtension-WordHighlighter/extension.php @@ -4,10 +4,7 @@ final class WordHighlighterExtension extends Minz_Extension { - private const JSON_ENCODE_CONF = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; - - public string $word_highlighter_conf = 'test'; - public string $permission_problem = ''; + public string $word_highlighter_conf = ''; public bool $enable_in_article = false; public bool $enable_logs = false; public bool $case_sensitive = false; @@ -16,82 +13,94 @@ final class WordHighlighterExtension extends Minz_Extension #[\Override] public function init(): void { $this->registerTranslates(); + $this->registerHook(Minz_HookType::JsVars, [$this, 'jsVars']); // register CSS for WordHighlighter: Minz_View::appendStyle($this->getFileUrl('style.css')); Minz_View::appendScript($this->getFileUrl('mark.min.js'), cond: false, defer: false, async: false); - $current_user = Minz_Session::paramString('currentUser'); - - $staticPath = join_path($this->getPath(), 'static'); - $configFileJs = join_path($staticPath, 'config.' . $current_user . '.js'); - - if (file_exists($configFileJs)) { - Minz_View::appendScript($this->getFileUrl('config.' . $current_user . '.js')); - } - Minz_View::appendScript($this->getFileUrl('word-highlighter.js')); } + /** + * @param array $vars + * @return array + */ + public function jsVars(array $vars): array { + $vars[$this->getName()]['configuration'] = $this->getWordHighlighterConfiguration(); + return $vars; + } + #[\Override] public function handleConfigureAction(): void { $this->registerTranslates(); - $current_user = Minz_Session::paramString('currentUser'); - $staticPath = join_path($this->getPath(), 'static'); - - $configFileJson = join_path($staticPath, ('config.' . $current_user . '.json')); - - if (!file_exists($configFileJson) && !is_writable($staticPath)) { - $tmpPath = explode(EXTENSIONS_PATH . '/', $staticPath); - $this->permission_problem = $tmpPath[1] . '/'; - } elseif (file_exists($configFileJson) && !is_writable($configFileJson)) { - $tmpPath = explode(EXTENSIONS_PATH . '/', $configFileJson); - $this->permission_problem = $tmpPath[1]; - } elseif (Minz_Request::isPost()) { - $configWordList = html_entity_decode(Minz_Request::paramString('words_list')); - - $this->word_highlighter_conf = $configWordList; - $this->enable_in_article = (bool) Minz_Request::paramString('enable-in-article'); - $this->enable_logs = (bool) Minz_Request::paramString('enable_logs'); - $this->case_sensitive = (bool) Minz_Request::paramString('case_sensitive'); - $this->separate_word_search = (bool) Minz_Request::paramString('separate_word_search'); - - $configObj = [ - 'enable_in_article' => $this->enable_in_article, - 'enable_logs' => $this->enable_logs, - 'case_sensitive' => $this->case_sensitive, - 'separate_word_search' => $this->separate_word_search, - 'words' => preg_split("/\r\n|\n|\r/", $configWordList) ?: [], - ]; - $configJson = json_encode($configObj, WordHighlighterExtension::JSON_ENCODE_CONF); - file_put_contents(join_path($staticPath, ('config.' . $current_user . '.json')), $configJson . PHP_EOL); - file_put_contents(join_path($staticPath, ('config.' . $current_user . '.js')), $this->jsonToJs($configJson) . PHP_EOL); + if (Minz_Request::isPost()) { + $this->setUserConfiguration([ + 'words' => $this->normalizeWords(preg_split('/\r\n|\n|\r/', Minz_Request::paramString('words_list', true)) ?: []), + 'enable_in_article' => Minz_Request::paramBoolean('enable-in-article'), + 'enable_logs' => Minz_Request::paramBoolean('enable_logs'), + 'case_sensitive' => Minz_Request::paramBoolean('case_sensitive'), + 'separate_word_search' => Minz_Request::paramBoolean('separate_word_search'), + ]); } - if (file_exists($configFileJson)) { - try { - $confJson = json_decode(file_get_contents($configFileJson) ?: '', true, 8, JSON_THROW_ON_ERROR); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($confJson)) { - return; + $configuration = $this->getWordHighlighterConfiguration(); + $this->word_highlighter_conf = implode("\n", $configuration['words']); + $this->enable_in_article = $configuration['enable_in_article']; + $this->enable_logs = $configuration['enable_logs']; + $this->case_sensitive = $configuration['case_sensitive']; + $this->separate_word_search = $configuration['separate_word_search']; + } + + /** + * Settings from the user configuration, or from the legacy per-user JSON file + * (static/config..json, written by versions up to 0.0.4) until they are saved once. + * @return array{words:list,enable_in_article:bool,enable_logs:bool,case_sensitive:bool,separate_word_search:bool} + */ + private function getWordHighlighterConfiguration(): array { + $configuration = $this->getUserConfiguration(); + if (!array_key_exists('words', $configuration)) { + $current_user = Minz_User::name() ?? ''; + if ($current_user === '') { + $current_user = Minz_Session::paramString('currentUser'); + } + if (preg_match('/\A[A-Za-z0-9_.-]+\z/', $current_user) === 1) { + $configFileJson = join_path($this->getPath(), 'static', 'config.' . $current_user . '.json'); + if (is_file($configFileJson) && is_readable($configFileJson)) { + try { + $legacyConfiguration = json_decode(file_get_contents($configFileJson) ?: '', true, 8, JSON_THROW_ON_ERROR); + if (is_array($legacyConfiguration)) { + $configuration = $legacyConfiguration; + } + } catch (JsonException $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Ignore invalid legacy configuration. + } } - $this->enable_in_article = (bool) ($confJson['enable_in_article'] ?? false); - $this->enable_logs = (bool) ($confJson['enable_logs'] ?? false); - $this->case_sensitive = (bool) ($confJson['case_sensitive'] ?? false); - $this->separate_word_search = (bool) ($confJson['separate_word_search'] ?? false); - $words = is_array($confJson['words']) ? array_filter($confJson['words'], static fn($word) => is_string($word)) : []; - $this->word_highlighter_conf = implode("\n", $words); - } catch (Exception $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch - // probably nothing to do needed } } + + return [ + 'words' => $this->normalizeWords(is_array($configuration['words'] ?? null) ? $configuration['words'] : []), + 'enable_in_article' => ($configuration['enable_in_article'] ?? false) === true, + 'enable_logs' => ($configuration['enable_logs'] ?? false) === true, + 'case_sensitive' => ($configuration['case_sensitive'] ?? false) === true, + 'separate_word_search' => ($configuration['separate_word_search'] ?? false) === true, + ]; } - private function jsonToJs(string $jsonStr): string { - $js = "window.WordHighlighterConf = " . - $jsonStr . ";\n" . - "window.WordHighlighterConf.enable_logs && console.log('WordHighlighter: loaded user config:', window.WordHighlighterConf);"; - return $js; + /** + * @param array $words + * @return list + */ + private function normalizeWords(array $words): array { + $result = []; + foreach ($words as $word) { + if (is_string($word) && trim($word) !== '') { + $result[] = trim($word); + } + } + return array_values(array_unique($result)); } } diff --git a/xExtension-WordHighlighter/metadata.json b/xExtension-WordHighlighter/metadata.json index 277eb39f..d854c4b9 100644 --- a/xExtension-WordHighlighter/metadata.json +++ b/xExtension-WordHighlighter/metadata.json @@ -2,7 +2,7 @@ "name": "Word highlighter", "author": "Lukas Melega", "description": "Highlight specific words", - "version": "0.0.4", + "version": "0.0.5", "entrypoint": "WordHighlighter", "type": "user" } diff --git a/xExtension-WordHighlighter/static/.gitignore b/xExtension-WordHighlighter/static/.gitignore index 6d6756d2..d4fdbfc9 100644 --- a/xExtension-WordHighlighter/static/.gitignore +++ b/xExtension-WordHighlighter/static/.gitignore @@ -1,2 +1,4 @@ config-words.*.js config-words.*.txt +config.*.json +config.*.js diff --git a/xExtension-WordHighlighter/static/word-highlighter.js b/xExtension-WordHighlighter/static/word-highlighter.js index 97b3689d..c0575465 100644 --- a/xExtension-WordHighlighter/static/word-highlighter.js +++ b/xExtension-WordHighlighter/static/word-highlighter.js @@ -1,4 +1,5 @@ 'use strict'; +/* globals context */ /* WordHighlighter v0.0.2 (FreshRSS Extension) */ @@ -55,22 +56,68 @@ function wordHighlighter(c /* console */, Mark, context, OPTIONS) { // MAIN: (function main() { - try { - const confName = 'WordHighlighterConf'; - const OPTIONS = window[confName] || { }; - const onMainPage = !(new URL(window.location)).searchParams.get('c'); - if (onMainPage) { - console.log('WordHighlighter: script load...'); - const context = document.querySelector('#stream'); - wordHighlighter(console, window.Mark || (Error('mark.js library is not loaded ❗️')), context, OPTIONS); - console.log('WordHighlighter: script loaded.✅'); - } else { - OPTIONS.enable_logs && console.log('WordHighlighter: ❗️ paused outside of feed page'); + let started = false; + let interval = null; + + function hasGlobalContext() { + return typeof context !== 'undefined' && context && context.extensions; + } + + function start() { + if (started) { + return; } - return Promise.resolve(); - } catch (error) { - console.error('WordHighlighter: ❌', error); - return Promise.reject(error); + started = true; + clearInterval(interval); + document.removeEventListener('freshrss:globalContextLoaded', start); + try { + const configuration = (hasGlobalContext() && context.extensions['Word highlighter'] && + context.extensions['Word highlighter'].configuration) || window.WordHighlighterConf || { }; + const OPTIONS = { + ...configuration, + words: Array.isArray(configuration.words) + ? configuration.words.filter((word) => typeof word === 'string').map((word) => word.trim()).filter((word) => word !== '') + : [], + }; + const onMainPage = !(new URL(window.location)).searchParams.get('c'); + if (onMainPage) { + // This script is loaded async and may run before the parser has reached #stream: + // wait for the DOM, otherwise Mark/MutationObserver get a null context and nothing is highlighted. + const run = () => { + const stream = document.querySelector('#stream'); + if (!stream) { + OPTIONS.enable_logs && console.log('WordHighlighter: ❗️ no #stream on this page'); + return; + } + console.log('WordHighlighter: script load...'); + wordHighlighter(console, window.Mark || (Error('mark.js library is not loaded ❗️')), stream, OPTIONS); + console.log('WordHighlighter: script loaded.✅'); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run, { once: true }); + } else { + run(); + } + } else { + OPTIONS.enable_logs && console.log('WordHighlighter: ❗️ paused outside of feed page'); + } + return Promise.resolve(); + } catch (error) { + console.error('WordHighlighter: ❌', error); + return Promise.reject(error); + } + } + + if (hasGlobalContext()) { + start(); + } else { + document.addEventListener('freshrss:globalContextLoaded', start); + let attempts = 0; + interval = setInterval(() => { + if (hasGlobalContext() || ++attempts >= 100) { + start(); + } + }, 50); } })(); From 7b3c14eeacadf4c860c90ffcb5c61b41fd69ae0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 10:33:58 +0000 Subject: [PATCH 2/2] Fix Word highlighter phpstan errors against FreshRSS edge Replace deprecated getUserConfiguration() and setUserConfiguration() with the typed getters and setUserConfigurationValue(), and assign the js_vars configuration without indexing a mixed value. Co-authored-by: JinHao Yang --- xExtension-WordHighlighter/extension.php | 86 ++++++++++++++++-------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/xExtension-WordHighlighter/extension.php b/xExtension-WordHighlighter/extension.php index 1c828b9b..b6d127d7 100644 --- a/xExtension-WordHighlighter/extension.php +++ b/xExtension-WordHighlighter/extension.php @@ -13,7 +13,7 @@ final class WordHighlighterExtension extends Minz_Extension #[\Override] public function init(): void { $this->registerTranslates(); - $this->registerHook(Minz_HookType::JsVars, [$this, 'jsVars']); + $this->registerHook(Minz_HookType::JsVars, $this->jsVars(...)); // register CSS for WordHighlighter: Minz_View::appendStyle($this->getFileUrl('style.css')); @@ -28,7 +28,14 @@ public function init(): void { * @return array */ public function jsVars(array $vars): array { - $vars[$this->getName()]['configuration'] = $this->getWordHighlighterConfiguration(); + $name = $this->getName(); + // $vars values are mixed; copy an existing array before adding the configuration key. + $extensionVars = []; + if (isset($vars[$name]) && is_array($vars[$name])) { + $extensionVars = $vars[$name]; + } + $extensionVars['configuration'] = $this->getWordHighlighterConfiguration(); + $vars[$name] = $extensionVars; return $vars; } @@ -37,13 +44,16 @@ public function handleConfigureAction(): void { $this->registerTranslates(); if (Minz_Request::isPost()) { - $this->setUserConfiguration([ - 'words' => $this->normalizeWords(preg_split('/\r\n|\n|\r/', Minz_Request::paramString('words_list', true)) ?: []), - 'enable_in_article' => Minz_Request::paramBoolean('enable-in-article'), - 'enable_logs' => Minz_Request::paramBoolean('enable_logs'), - 'case_sensitive' => Minz_Request::paramBoolean('case_sensitive'), - 'separate_word_search' => Minz_Request::paramBoolean('separate_word_search'), - ]); + // setUserConfigurationValue() saves on every call. Edge has no multi-key + // write, so the flat keys are stored one at a time. + $this->setUserConfigurationValue( + 'words', + $this->normalizeWords(preg_split('/\r\n|\n|\r/', Minz_Request::paramString('words_list', true)) ?: []) + ); + $this->setUserConfigurationValue('enable_in_article', Minz_Request::paramBoolean('enable-in-article')); + $this->setUserConfigurationValue('enable_logs', Minz_Request::paramBoolean('enable_logs')); + $this->setUserConfigurationValue('case_sensitive', Minz_Request::paramBoolean('case_sensitive')); + $this->setUserConfigurationValue('separate_word_search', Minz_Request::paramBoolean('separate_word_search')); } $configuration = $this->getWordHighlighterConfiguration(); @@ -60,25 +70,17 @@ public function handleConfigureAction(): void { * @return array{words:list,enable_in_article:bool,enable_logs:bool,case_sensitive:bool,separate_word_search:bool} */ private function getWordHighlighterConfiguration(): array { - $configuration = $this->getUserConfiguration(); - if (!array_key_exists('words', $configuration)) { - $current_user = Minz_User::name() ?? ''; - if ($current_user === '') { - $current_user = Minz_Session::paramString('currentUser'); - } - if (preg_match('/\A[A-Za-z0-9_.-]+\z/', $current_user) === 1) { - $configFileJson = join_path($this->getPath(), 'static', 'config.' . $current_user . '.json'); - if (is_file($configFileJson) && is_readable($configFileJson)) { - try { - $legacyConfiguration = json_decode(file_get_contents($configFileJson) ?: '', true, 8, JSON_THROW_ON_ERROR); - if (is_array($legacyConfiguration)) { - $configuration = $legacyConfiguration; - } - } catch (JsonException $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch - // Ignore invalid legacy configuration. - } - } - } + $storedWords = $this->getUserConfigurationArray('words'); + if ($storedWords === null) { + $configuration = $this->readLegacyConfiguration(); + } else { + $configuration = [ + 'words' => $storedWords, + 'enable_in_article' => $this->getUserConfigurationBool('enable_in_article'), + 'enable_logs' => $this->getUserConfigurationBool('enable_logs'), + 'case_sensitive' => $this->getUserConfigurationBool('case_sensitive'), + 'separate_word_search' => $this->getUserConfigurationBool('separate_word_search'), + ]; } return [ @@ -90,6 +92,34 @@ private function getWordHighlighterConfiguration(): array { ]; } + /** + * Legacy per-user file from versions up to 0.0.4. Read-only; never written. + * @return array + */ + private function readLegacyConfiguration(): array { + $current_user = Minz_User::name() ?? ''; + if ($current_user === '') { + $current_user = Minz_Session::paramString('currentUser'); + } + if (preg_match('/\A[A-Za-z0-9_.-]+\z/', $current_user) !== 1) { + return []; + } + + $configFileJson = join_path($this->getPath(), 'static', 'config.' . $current_user . '.json'); + if (!is_file($configFileJson) || !is_readable($configFileJson)) { + return []; + } + + try { + $legacyConfiguration = json_decode(file_get_contents($configFileJson) ?: '', true, 8, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch + // Ignore invalid legacy configuration. + return []; + } + + return is_array($legacyConfiguration) ? $legacyConfiguration : []; + } + /** * @param array $words * @return list