├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── xExtension-FeedPriorityShortcut ├── extension.php ├── metadata.json └── static │ ├── main.css │ └── main.js └── xExtension-ThemeModeSynchronizer ├── configure.phtml ├── extension.php ├── i18n ├── en │ └── ext.php └── zh-cn │ └── ext.php ├── metadata.json └── static └── main.js /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "docker-run", 6 | "label": "docker-dev", 7 | "dockerRun": { 8 | "containerName": "freshrss-extensions", 9 | "image": "freshrss/freshrss:latest", 10 | "options": [ 11 | "--rm", 12 | ], 13 | "ports": [ 14 | { 15 | "containerPort": 80, 16 | "hostPort": 3000 17 | } 18 | ], 19 | "volumes": [ 20 | { 21 | "containerPath": "/var/www/FreshRSS/extensions", 22 | "localPath": "${workspaceFolder}" 23 | } 24 | ], 25 | "remove": true 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Aidi Tan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FreshRSS extensions 2 | 3 | > **NOTE for edge users**: please use the versions in [edge branch](https://github.com/aidistan/freshrss-extensions/tree/edge) instead. 4 | 5 | This repository contains all my custom FreshRSS extensions. 6 | 7 | ## How to install? 8 | 9 | ```sh 10 | # Clone this repository 11 | cd /var/www/FreshRSS/extensions 12 | git clone --depth=1 https://github.com/aidistan/freshrss-extensions.git aidistan-extensions 13 | 14 | # Checkout the edge branch (optional, only for edge users) 15 | cd aidistan-extensions 16 | git checkout edge 17 | cd .. 18 | 19 | # Copy the desired extension 20 | cp -r aidistan-extensions/xExtension-XXX . 21 | ``` 22 | 23 | See also: https://freshrss.github.io/FreshRSS/en/admins/15_extensions.html#how-to-install 24 | 25 | ## How to upgrade? 26 | 27 | ```sh 28 | # Update the repository 29 | cd /var/www/FreshRSS/extensions/aidistan-extensions 30 | git pull 31 | cd .. 32 | 33 | # Remove the old version 34 | rm -rf xExtension-XXX 35 | 36 | # Copy the new version 37 | cp -r aidistan-extensions/xExtension-XXX . 38 | ``` 39 | 40 | ## Quick glance 41 | 42 | ### Theme Mode Synchronizer 43 | 44 | Synchronize the theme with your system light/dark mode 45 | 46 | https://user-images.githubusercontent.com/3037661/185191808-af1e375c-e9e5-41ca-942a-8b714f50a774.mp4 47 | 48 | ### Feed Priority Shortcut 49 | 50 | Set up visibilities/priorities of your feeds easily 51 | 52 |  53 | -------------------------------------------------------------------------------- /xExtension-FeedPriorityShortcut/extension.php: -------------------------------------------------------------------------------- 1 | registerHook('js_vars', [$this, 'provideFeedPrioritiesInJS']); 8 | Minz_View::appendScript($this->getFileUrl('main.js', 'js')); 9 | Minz_View::appendStyle($this->getFileUrl('main.css', 'css')); 10 | } 11 | } 12 | 13 | public function handleConfigureAction() { 14 | if (Minz_Request::isPost()) { 15 | $feedDAO = FreshRSS_Factory::createFeedDao(); 16 | $feedDAO->updateFeed(Minz_Request::param('feed_id'), [ 17 | 'priority' => [ 18 | '📌' => FreshRSS_Feed::PRIORITY_IMPORTANT, 19 | '🏠' => FreshRSS_Feed::PRIORITY_MAIN_STREAM, 20 | '📁' => FreshRSS_Feed::PRIORITY_CATEGORY, 21 | '🔒' => FreshRSS_Feed::PRIORITY_ARCHIVED 22 | ][Minz_Request::param('priority')] 23 | ]); 24 | } 25 | } 26 | 27 | public function provideFeedPrioritiesInJS($vars) { 28 | $feedDAO = FreshRSS_Factory::createFeedDao(); 29 | 30 | return array_merge($vars, ['FeedPriorityShortcut' => [ 31 | 'postUrl' => _url('extension', 'configure', 'e', $this->getName()), 32 | 'priority' => array_map(function($feed) { 33 | return [ 34 | FreshRSS_Feed::PRIORITY_IMPORTANT => '📌', 35 | FreshRSS_Feed::PRIORITY_MAIN_STREAM => '🏠', 36 | FreshRSS_Feed::PRIORITY_CATEGORY => '📁', 37 | FreshRSS_Feed::PRIORITY_ARCHIVED => '🔒' 38 | ][$feed -> priority()]; 39 | }, $feedDAO->listFeeds()), 40 | 'tooltips' => array( 41 | 'important' => _t('sub.feed.priority.important'), 42 | 'main_stream' => _t('sub.feed.priority.main_stream'), 43 | 'category' => _t('sub.feed.priority.category'), 44 | 'archived' => _t('sub.feed.priority.archived') 45 | ) 46 | ]]); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /xExtension-FeedPriorityShortcut/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Feed Priority Shortcut", 3 | "author": "Aidi Stan", 4 | "description": "Set up visibilities/priorities of your feeds easily", 5 | "version": "1.1.0", 6 | "entrypoint": "FeedPriorityShortcut", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-FeedPriorityShortcut/static/main.css: -------------------------------------------------------------------------------- 1 | .feed-priority-shortcut { 2 | float: right; 3 | } 4 | 5 | .feed-priority-shortcut .dropdown-toggle:hover { 6 | text-decoration: none; 7 | } 8 | 9 | .feed-priority-shortcut .dropdown-menu { 10 | min-width: calc(8rem + 10px); 11 | padding: 0; 12 | } 13 | 14 | @media (max-width: 840px) { 15 | .feed-priority-shortcut.dropdown { 16 | position: relative; 17 | } 18 | 19 | .feed-priority-shortcut .dropdown-menu { 20 | width: calc(8rem + 10px); 21 | left: inherit; 22 | right: 0px; 23 | } 24 | } 25 | 26 | .feed-priority-shortcut .dropdown-menu .item { 27 | display: inline-block; 28 | width: 2rem; 29 | text-align: center; 30 | cursor: pointer; 31 | } 32 | -------------------------------------------------------------------------------- /xExtension-FeedPriorityShortcut/static/main.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | 3 | (() => { 4 | const context = window.context.extensions.FeedPriorityShortcut 5 | 6 | // Decode double-html-encoded postUrl 7 | const txt = document.createElement('textarea') 8 | txt.innerHTML = context.postUrl // once for js_vars 9 | txt.innerHTML = txt.value // once for _url 10 | context.postUrl = txt.value 11 | })() 12 | 13 | // Add dropdowns 14 | document.querySelectorAll('.feed.item').forEach((li) => { 15 | if (!li.dataset.feedId) { return } 16 | 17 | const i = li.dataset.feedId 18 | const p = context.extensions.FeedPriorityShortcut.priority[i] 19 | const t = context.extensions.FeedPriorityShortcut.tooltips 20 | const d = document.createElement('div') 21 | 22 | d.classList.add('feed-priority-shortcut', 'dropdown') 23 | d.innerHTML = 24 | `
25 | ${p} 26 | 32 | ❌` 33 | 34 | li.appendChild(d) 35 | }) 36 | 37 | // Add listeners 38 | document.querySelectorAll('.feed-priority-shortcut .item').forEach((li) => { 39 | li.addEventListener('click', (e) => { 40 | fetch(context.extensions.FeedPriorityShortcut.postUrl, { 41 | method: 'POST', 42 | headers: { 'Content-Type': 'application/json' }, 43 | body: JSON.stringify({ 44 | _csrf: context.csrf, 45 | feed_id: e.target.closest('.feed.item').dataset.feedId, 46 | priority: e.target.textContent 47 | }) 48 | }) 49 | .then(() => location.reload()) 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /xExtension-ThemeModeSynchronizer/configure.phtml: -------------------------------------------------------------------------------- 1 | 43 | -------------------------------------------------------------------------------- /xExtension-ThemeModeSynchronizer/extension.php: -------------------------------------------------------------------------------- 1 | dark_theme = FreshRSS_Context::$user_conf->theme; 7 | FreshRSS_Context::$user_conf->light_theme = FreshRSS_Context::$user_conf->theme; 8 | FreshRSS_Context::$user_conf->save(); 9 | return true; 10 | } 11 | 12 | public function init() { 13 | $this->registerTranslates(); 14 | $this->registerHook('js_vars', [$this, 'providePreferredThemesInJs']); 15 | Minz_View::appendScript($this->getFileUrl('main.js', 'js')); 16 | } 17 | 18 | public function handleConfigureAction() { 19 | if (Minz_Request::isPost()) { 20 | foreach (['dark_theme', 'light_theme', 'theme'] as $param) { 21 | if (Minz_Request::param($param)) { 22 | FreshRSS_Context::$user_conf->_param($param, Minz_Request::param($param)); 23 | } 24 | } 25 | FreshRSS_Context::$user_conf->save(); 26 | } 27 | } 28 | 29 | public function providePreferredThemesInJs($vars) { 30 | return array_merge($vars, ['ThemeModeSynchronizer' => [ 31 | 'darkTheme' => FreshRSS_Context::$user_conf->dark_theme, 32 | 'lightTheme' => FreshRSS_Context::$user_conf->light_theme, 33 | 'postUrl' => _url('extension', 'configure', 'e', $this->getName()), 34 | 'warning' => array_map(function($value) { 35 | return _t('ext.theme_mode_synchronizer.warning.' . $value); 36 | }, [ 37 | 'anonymous' => 'anonymous', 38 | 'unsupported' => 'unsupported' 39 | ]) 40 | ]]); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /xExtension-ThemeModeSynchronizer/i18n/en/ext.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'dark_theme' => 'Dark Theme', 6 | 'light_theme' => 'Light Theme', 7 | 'warning' => [ 8 | 'anonymous' => 'Due to lack of authorization, ThemeModeSynchronizer extension cannot work as expected.', 9 | 'unsupported' => 'Due to the limitation of current browser, ThemeModeSynchronizer extension cannot work as expected.' 10 | ] 11 | ] 12 | ]; 13 | -------------------------------------------------------------------------------- /xExtension-ThemeModeSynchronizer/i18n/zh-cn/ext.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'dark_theme' => '深色主题', 6 | 'light_theme' => '浅色主题', 7 | 'warning' => [ 8 | 'anonymous' => '受匿名阅读模式限制,Theme Mode Synchronizer 插件无法正常工作。', 9 | 'unsupported' => '受当前浏览器限制,Theme Mode Synchronizer 插件无法正常工作。' 10 | ] 11 | ] 12 | ]; 13 | -------------------------------------------------------------------------------- /xExtension-ThemeModeSynchronizer/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Theme Mode Synchronizer", 3 | "author": "Aidi Stan", 4 | "description": "Synchronize the theme with your system light/dark mode", 5 | "version": "1.1.4", 6 | "entrypoint": "ThemeModeSynchronizer", 7 | "type": "user" 8 | } 9 | -------------------------------------------------------------------------------- /xExtension-ThemeModeSynchronizer/static/main.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', () => { 2 | 3 | (() => { 4 | const context = window.context.extensions.ThemeModeSynchronizer 5 | 6 | // Decode double-html-encoded postUrl 7 | const txt = document.createElement('textarea') 8 | txt.innerHTML = context.postUrl // once for js_vars 9 | txt.innerHTML = txt.value // once for _url 10 | context.postUrl = txt.value 11 | })() 12 | 13 | function getCurrentTheme() { 14 | for (node of document.querySelectorAll('link[rel="stylesheet"]')) { 15 | const match = new RegExp('/themes/([^/]+)/').exec(node.attributes.href?.value) 16 | if (match && match[1] !== 'base-theme') { 17 | return match[1] 18 | } 19 | } 20 | } 21 | 22 | function getSystemMode() { 23 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 24 | return 'dark' 25 | } else { 26 | return 'light' 27 | } 28 | } 29 | 30 | function syncWithSystemMode(callTiming) { 31 | const theme = getCurrentTheme() 32 | const preferredTheme = context.extensions.ThemeModeSynchronizer[getSystemMode() + 'Theme'] 33 | 34 | if (preferredTheme && theme !== preferredTheme) { 35 | fetch(context.extensions.ThemeModeSynchronizer.postUrl, { 36 | method: 'POST', 37 | headers: { 'Content-Type': 'application/json' }, 38 | body: JSON.stringify({ 39 | _csrf: context.csrf, 40 | theme: preferredTheme 41 | }) 42 | }) 43 | .then(() => { 44 | const duringReading = callTiming === 'onChange' && 45 | ['normal', 'global', 'reader'].includes(context.current_view) 46 | 47 | if (!duringReading) location.reload() 48 | }) 49 | } 50 | } 51 | 52 | if (context.anonymous) { 53 | return console.log(context.extensions.ThemeModeSynchronizer.warning.anonymous) 54 | } else if (!window.matchMedia) { 55 | return console.log(context.extensions.ThemeModeSynchronizer.warning.unsupported) 56 | } else { 57 | syncWithSystemMode('onLoad') 58 | 59 | window.matchMedia('(prefers-color-scheme: dark)') 60 | .addEventListener('change', () => syncWithSystemMode('onChange')) 61 | } 62 | }) 63 | --------------------------------------------------------------------------------