Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions xExtension-WordHighlighter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<user>.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)
Expand Down
14 changes: 5 additions & 9 deletions xExtension-WordHighlighter/configure.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ declare(strict_types=1);
</label>
<div class="group-controls">
<textarea name="words_list"
id="words_list"><?= $this->word_highlighter_conf ?></textarea>
id="words_list"><?= htmlspecialchars($this->word_highlighter_conf, ENT_QUOTES, 'UTF-8') ?></textarea>
</div>
</div>

Expand Down Expand Up @@ -75,14 +75,10 @@ declare(strict_types=1);
</details>

<div class="form-group form-actions">
<?php if ($this->permission_problem !== '') { ?>
<p class="alert alert-error"><?= _t('ext.word_highlighter.permission_problem', $this->permission_problem) ?></p>
<?php } else { ?>
<div class="group-controls">
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
</div>
<?php } ?>
<div class="group-controls">
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
</div>
</div>

</form>
161 changes: 100 additions & 61 deletions xExtension-WordHighlighter/extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,82 +13,124 @@ 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');
Minz_View::appendScript($this->getFileUrl('word-highlighter.js'));
}

if (file_exists($configFileJs)) {
Minz_View::appendScript($this->getFileUrl('config.' . $current_user . '.js'));
/**
* @param array<string,mixed> $vars
* @return array<string,mixed>
*/
public function jsVars(array $vars): array {
$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];
}

Minz_View::appendScript($this->getFileUrl('word-highlighter.js'));
$extensionVars['configuration'] = $this->getWordHighlighterConfiguration();
$vars[$name] = $extensionVars;
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) ?: [],
if (Minz_Request::isPost()) {
// 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();
$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.<user>.json, written by versions up to 0.0.4) until they are saved once.
* @return array{words:list<string>,enable_in_article:bool,enable_logs:bool,case_sensitive:bool,separate_word_search:bool}
*/
private function getWordHighlighterConfiguration(): array {
$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'),
];
$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 (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;
}
$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,
];
}

/**
* Legacy per-user file from versions up to 0.0.4. Read-only; never written.
* @return array<mixed>
*/
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 : [];
}

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<mixed> $words
* @return list<string>
*/
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));
}
}
2 changes: 1 addition & 1 deletion xExtension-WordHighlighter/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
2 changes: 2 additions & 0 deletions xExtension-WordHighlighter/static/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
config-words.*.js
config-words.*.txt
config.*.json
config.*.js
77 changes: 62 additions & 15 deletions xExtension-WordHighlighter/static/word-highlighter.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use strict';
/* globals context */

/* WordHighlighter v0.0.2 (FreshRSS Extension) */

Expand Down Expand Up @@ -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);
}
})();

Expand Down