Compare commits

...

314 Commits

Author SHA1 Message Date
Andrew Dolgov 9f734c9050 minor phpstan tweaks 3 years ago
Andrew Dolgov 3b70d1f622 require phpstan via composer 3 years ago
Andrew Dolgov 2a5c2be6cd af_redditimgur: allow subscribing to teddit.net subreddits directly (rewriting to reddit.com) 3 years ago
Andrew Dolgov 41245da8a6 pluginhost: update comments for HOOK_ constants to use phpdoc syntax; add HOOK_PRE_SUBSCRIBE 3 years ago
Andrew Dolgov 6fe0751038 af_redditimgur: set some @var hints 3 years ago
Andrew Dolgov 377e0b812c add data-orig-feed-title to generated headline markup 3 years ago
Andrew Dolgov dd30825b94 af_comics: pass PluginHost to filter constructors 3 years ago
fox d1ffe6d6cf Merge pull request 'Fix undefined array key "output" when adding new label' (#47) from klempin/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/47
3 years ago
Philip Klempin aead30a041 Fix undefined array key "output" when adding new label 3 years ago
Andrew Dolgov a936e80630 OPML improvements/fixes:
* allow CLI import of OPML files (--opml-import)
 * visualize OPML structure when importing
 * add strict type hints to most OPML class methods
3 years ago
Andrew Dolgov 9e7e0e84d7 fix vfeed menu in three panel mode 3 years ago
Andrew Dolgov c6f5902cbc fix wrongly renamed CLI options --debug-force-... to --force-... 3 years ago
Andrew Dolgov a9646b9574 headlines: attach context menu to vfeed title node 3 years ago
Andrew Dolgov 145fc31625 feed tree context menu: add an entry to open originating website 3 years ago
Andrew Dolgov 949e2ab4d2 properly sanitize video poster attribute 3 years ago
Andrew Dolgov 8ed927dbd2 OPML: multiple fixes
- remove unused integer indexes when exporting filters as JSON
 - fix warning when importing filters without rules
 - properly assign category IDs for category filter rules
 - fix warning: check if outline attributes like xmlUrl are set before trying to use them
 - fix warning: don't try to use libxml_disable_entity_loader on PHP 8
3 years ago
Andrew Dolgov 78ff7770d1 classes/opml: fix indentation; when importing, don't produce warning
on filters with no rules defined.
3 years ago
fox 012a9fdee3 Merge pull request 'Fix undefined index error' (#45) from jpschewe/tt-rss:fix-undefined-index into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/45
3 years ago
Jon Schewe e44f0cb937 Fix undefined index error
Getting $op is handled at the top of the file, use the same variable
at the end of the file to avoid errors about an undefined index.
3 years ago
Andrew Dolgov 36e174750e fix label ordering in feed tree 3 years ago
fox b8f82ca12f Merge pull request 'fix password recovery' (#44) from mechnich/tt-rss:fix-password-recovery into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/44
3 years ago
jmechnich e8f9567d79 fix password recovery 3 years ago
Andrew Dolgov a1173ab06a block useless usort() E_DEPRECATED for the time being 3 years ago
Andrew Dolgov 2c931df77c remove SELF_USER_AGENT custom constant, replaced with configurable Config::HTTP_USER_AGENT / Config::get_user_agent() 3 years ago
Andrew Dolgov 5c60254474 Pref_Feeds:calculate_children_count - fix operator precedence 3 years ago
Andrew Dolgov 0808123179 fix broken feed tree generation when categories are disabled 3 years ago
fox 5ed108dce4 Merge pull request 'Use ORM more in 'classes/pref/feeds.php'.' (#43) from wn/tt-rss:feature/pref-feeds-idiorm into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/43
3 years ago
fox 28eafa2bcd Merge pull request 'pull latest readability-php via composer' (#42) from niehztog/tt-rss:update-readability into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/42
3 years ago
wn_ 23b4152c9e Make prefs feed search case-insensitive.
Previously the search query had to match lower title or feed_url (i.e. searching w/ uppercase wouldn't match).
3 years ago
wn_ 992e9cd9e3 Use ORM more in 'classes/pref/feeds.php'. 3 years ago
Nils Gotzhein b6b6771d8d pull latest readability-php via composer 3 years ago
fox a73e3bec45 Merge pull request 'Use ORM in some more parts of 'update.php'.' (#41) from wn/tt-rss:feature/update-use-idiorm into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/41
3 years ago
wn_ cf0ec06b8c Use ORM in some more parts of 'update.php'. 3 years ago
Andrew Dolgov 73d14338ab fix rendering of category filters on Uncategorized 3 years ago
Andrew Dolgov 9669bb94de main toolbar: clarify element ordering, fix some indents 3 years ago
Andrew Dolgov 44c5d0feba prolong PHP session cookie automatically to stop hard logouts after SESSION_COOKIE_LIFETIME expires 3 years ago
fox cd26dbe64c Merge pull request 'Rewrite feed entry link as href content' (#40) from klempin/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/40
3 years ago
Philip Klempin 14d57d9a14 Rewrite feed entry link as href content 3 years ago
fox 7bd9572aa1 Merge pull request 'Fix operator precedence' (#39) from klempin/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/39
3 years ago
Philip Klempin 1d4d3bc49c Fix operator precedence 3 years ago
Weblate f16fc3bf41 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/
3 years ago
Andrew Dolgov 93a5ba55d3 rebase translations 3 years ago
Andrew Dolgov 800ebd6373 revise previous a little bit more 3 years ago
Andrew Dolgov 69f261c41d revise previous a little bit 3 years ago
Andrew Dolgov e9c062a189 UrlHelper::rewrite_relative():
- support invoking specifying owner URL element/attribute
 - restrict mailto/magnet/tel schemes for A href
 - allow some data: base64 image types for IMG src

Sanitizer::sanitize():

 - when checking href and src attributes, pass element tagname and attribute to rewrite_relative()
3 years ago
fox 34807bacd4 Merge pull request 'Skip all urls with schemes different from base_url in rewrite_relative' (#38) from klempin/tt-rss:fix/mailto into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/38
3 years ago
Andrew Dolgov 4e9c3500fb clarify some @deprecation notices 3 years ago
Philip Klempin b3bedd0a94 Skip URI base on ALLOWED_RELATIVE_SCHEMES in rewrite_relative 3 years ago
Andrew Dolgov 8ed8a10965 add settings profile cloning 3 years ago
Andrew Dolgov 92c78beb90 apply usort workaround for readability-php because its authors were unable to do so for 3 months (https://github.com/andreskrey/readability.php/issues/99) 3 years ago
Andrew Dolgov 8e1281b41e add workaround for prefs feed tree favicon placement 3 years ago
Andrew Dolgov 326850845d UrlHelper::rewrite_relative: don't try to feed NULL to with_trailing_slash() 3 years ago
Andrew Dolgov dff479af64 feeditem_atom: support xml:base for enclosures and entry content
UrlHelper::rewrite_relative: use base URL path if relative url path is not absolute (experimental)
3 years ago
Andrew Dolgov d09a64d6f9 split googlereaderkeys plugin into separate repo 3 years ago
Andrew Dolgov 8574532b7f add hotkeys J/K to move between unread feeds 3 years ago
Andrew Dolgov 4795c4a2a9 Merge branch 'weblate-integration' 3 years ago
Eike 0f51350e9f Translated using Weblate (German)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/de/
3 years ago
Andrew Dolgov 295fc1f88a API: bump api level to 17 3 years ago
Andrew Dolgov 2adf364c2c provide base configuration object in login response to skip on initial getConfig 3 years ago
Andrew Dolgov 9f6237a1b8 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 57cd8acfc9 API: return custom sort types in getConfig 3 years ago
fox 77031575ab Merge pull request 'Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived articles' (#35) from kdan/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/35
3 years ago
linkai 983655165e Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived 3 years ago
kdan 6c06a26649 Merge branch 'master' into master 3 years ago
Andrew Dolgov f423874e05 checking for PDO there is rather useless 3 years ago
Andrew Dolgov b5a559a1a7 sanity check: in single user mode, only test for admin user if migrations have been completed 3 years ago
Andrew Dolgov e3c4724dc1 use database-backed sessions in single user mode 3 years ago
fox 82749ee7a7 Merge pull request 'Improve missing token check' (#36) from skazi/tt-rss:quiet-csrf into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/36
3 years ago
Jacek Tomasiak 0c38dc8456 Improve missing token check
Avoid "E_NOTICE (8) (classes/userhelper.php:78) Undefined index:
csrf_token" in logs.
3 years ago
kdan 2ccf0e50a2 Merge branch 'master' into master 3 years ago
linkai acf0e0d266 Fix:Plugins-share:init.php - site_url is NULL when share article by URL from archived 3 years ago
Andrew Dolgov b2f888e386 include archived articles (which lack associated feed id) when browsing by tag 3 years ago
Andrew Dolgov fea59de26b af_redditimgur: use core youtube vid helper 3 years ago
Andrew Dolgov 86300a0ca8 add urlhelper to extract youtube video id from url 3 years ago
Andrew Dolgov d11718c89c fix combined/three panel transition to expandable mode 3 years ago
linkai 0574675ed6 Fix:Plugins-share:init.php - site_url is NULL when share atircle by URL form archived 3 years ago
Andrew Dolgov e8f78181f1 af_redditimgur: instead of generating potentially blacklisted iframes (i.e. huge black boxes),
save found youtube videos as post enclosures for af_youtube_... plugins to deal with later, if enabled
3 years ago
Andrew Dolgov 88a7130d79 fix for previous changeset that broke expanded mode 3 years ago
Andrew Dolgov e8e4fc641e Article.pack: add no-op for three panel mode 3 years ago
Andrew Dolgov df145c8064 * cdm: render enclosures into content element
* deprecate cdm.intermediate
 * implement lazy-load for rendered enclosures
 * simplify pack/unpack logic for articles
3 years ago
Andrew Dolgov c6befcddb7 Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov 5a71426ea5 youtube_embed: use embed-responsive 3 years ago
Andrew Dolgov b3d45a4a5d Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov cc634ba91d Merge branch 'weblate-integration' 3 years ago
Eike b12072fef9 Translated using Weblate (German)
Currently translated at 99.8% (659 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/de/
3 years ago
fox a6c5dda7e0 Merge pull request 'FIX: public.php - Undefined index: feed_title' (#34) from ohaucke/tt-rss:public-php-fix into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/34
3 years ago
Oliver Haucke cfd9e6b53b FIX: public.php - Undefined index: feed_title 3 years ago
fox 0f61675cd0 Merge pull request 'Fix `getCategory` method.' (#32) from rodneys_mission/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/32
3 years ago
Rodney Stromlund c18383d1ea Fix `getCategory` method. 3 years ago
Andrew Dolgov 3e22368962 getPreviousFeed/getNextFeed: implement wrap around 3 years ago
Andrew Dolgov eadaaebd58 functions_enabled: trim spaces from disable_functions php ini setting 3 years ago
fox 61b4a678ea Merge pull request 'if backend request 'op' is empty fixed' (#27) from Cyb10101/tt-rss:cyb-backend-op into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/27
3 years ago
Cyb10101 c15c1dfb0b if backend request 'op' is empty fixed 3 years ago
Andrew Dolgov a61348e2b7 pluginhost: add profile_get/profile_set helpers 3 years ago
Andrew Dolgov a5af15cfe9 fix noscript notifications 3 years ago
Andrew Dolgov 49ef15f11d * fonts-ui: use system font family instead of segoe, etc. by name
* disable segoe-specific baseline hack for the time being
3 years ago
Andrew Dolgov c0fba62fa0 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 0acd33abe3 OTP: generate longer secrets, also make them easier to read/copy 3 years ago
Андрій Жук 0294297ccc Translated using Weblate (Ukrainian)
Currently translated at 89.8% (593 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/uk/
3 years ago
Piotr a4f82fddf4 Translated using Weblate (Polish)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/pl/
3 years ago
Glandos 49b25a6430 Translated using Weblate (French)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/fr/
3 years ago
fox f2f2b6d1f4 Merge pull request 'Adjust quotation marks in search query before 'str_getcsv'.' (#26) from wn/tt-rss:search-quotation-marks into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/26
3 years ago
wn_ 5d5c034a90 Adjust quotation marks in search query before 'str_getcsv'.
This moves a potential first quotation mark to before the associated keyword to ensure 'str_getcsv' groups the key and value correctly.  Without this 'str_getcsv' would split on potential spaces within the quoted value.
3 years ago
fox 0b82afabd5 Merge pull request 'Fix automatically showing next feed on catchup' (#25) from wn/tt-rss:bugfix/on-catchup-show-next-feed into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/25
3 years ago
wn_ 2ed5a79e64 Fix automatically showing next feed on catchup 3 years ago
Andrew Dolgov 8c32ed76df revert back to lower contrast light theme by default, add separate light-high-contrast.less 3 years ago
Andrew Dolgov ceb8179ccc don't use css-defined .svg files because firefox 3 years ago
Andrew Dolgov 19c277391e fonts-ui: add Cantarell 3 years ago
Andrew Dolgov 58ab641fea light theme: increase contrast 3 years ago
Andrew Dolgov be2d1602bd fix previous issue properly 3 years ago
Andrew Dolgov e3c51b0e6c Revert "clip max displayed counter value to 9999 because of container node width"
This reverts commit c34a4c85bd.
3 years ago
Andrew Dolgov c34a4c85bd clip max displayed counter value to 9999 because of container node width 3 years ago
Andrew Dolgov 0f6644880a yet another flex feedtree attempt 3 years ago
Andrew Dolgov 98251022d4 Revert "Revert "another attempt at flex-based feed tree""
This reverts commit 43744412f4.
3 years ago
Andrew Dolgov 334a361e79 don't try to j/k move to nonexistant feed 3 years ago
Andrew Dolgov d275134f26 unify return values for getPreviousFeed and usages of both prev/next 3 years ago
Andrew Dolgov 2e6d48ead7 * Feeds.openNextUnread: fix
* model.getNextFeed: make sure return values are consistent, stop
wrapping back to starred
3 years ago
Andrew Dolgov 43744412f4 Revert "another attempt at flex-based feed tree"
This reverts commit e12a6ca540.
3 years ago
Andrew Dolgov ef5d6b9b78 Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov e12a6ca540 another attempt at flex-based feed tree 3 years ago
Andrew Dolgov 1f5adf1600 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 68299c914b share: move og:image back to head 3 years ago
fox 56f7b25e85 Merge pull request 'Switch most of API to ORM' (#23) from wn/tt-rss:orm-api into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/23
3 years ago
wn_ 711e8e70e0 Switch most of API to ORM
'updateArticle' was left as-is due to Idiorm not supporting efficient multi-row updating (i.e. it would do an UPDATE per row).
3 years ago
Andrew Dolgov 718c9f07fa remove model.getNextUnreadFeed; unify code with feedTree.getNextFeed 3 years ago
Andrew Dolgov 43ea36d030 prefs: allow setting email if it was previously blank 3 years ago
fox ce9955c6ff Merge pull request 'Switch Handler_Public to ORM' (#22) from wn/tt-rss:orm-handler-public into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/22
3 years ago
wn_ cd52ca80ab Minor cleanup in 'Handler_Public->getProfiles' 3 years ago
wn_ baf3ecd4cf Fix a couple of array index warnings in 'Handler_Public->forgotpass' 3 years ago
Andrew Dolgov 968270ed48 fix excessive CPU usage on linux chromium caused by animated SVG icons 3 years ago
wn_ 541a07250c Switch 'Handler_Public->forgotpass' to ORM 3 years ago
wn_ f057c124d1 Switch 'Handler_Public->login' to ORM, fix 'Handler_Public->getProfiles' 3 years ago
wn_ 7ea48f7a4b Switch 'Handler_Public->rss' to ORM 3 years ago
wn_ b6ae280446 Switch 'Handler_Public->getProfiles' to ORM 3 years ago
Andrew Dolgov db0315e596 feed tree: set cursor pointer on tree label 3 years ago
Andrew Dolgov 88534a8ae4 fix loadingNode offset for feeds 3 years ago
Andrew Dolgov 82bed1e651 filter test dialog: remove .gif; cleanup markup 3 years ago
fox 7c2b473d12 Merge pull request 'Switch 'RSSUtils::update_basic_info' to ORM' (#21) from wn/tt-rss:orm-update_basic_info into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/21
3 years ago
wn_ 401b22666d Switch 'RSSUtils::update_basic_info' to ORM 3 years ago
Andrew Dolgov 0f5fd9ea13 use svg icon for headlines loadmore prompt 3 years ago
Andrew Dolgov 32c080bec0 use svg icon for the subscribe dialog (night mode) 3 years ago
Andrew Dolgov 166517240e use svg icon for the subscribe dialog 3 years ago
Andrew Dolgov 7a1e1630d8 use svg icon for packed article placeholders 3 years ago
Andrew Dolgov 92f859add2 update night theme re: previous 3 years ago
Andrew Dolgov a0e41f41a4 add svg loading indicators 3 years ago
Andrew Dolgov 7ec8a6cad0 simplify feed tree expando/loading/feed icon handling 3 years ago
Andrew Dolgov d9ba403927 remove some hardcoded color values 3 years ago
Andrew Dolgov 44b274b6d4 remove published opml (use CLI instead) 3 years ago
fox c134aa387d Merge pull request 'Fix E_NOTICE in add_handler().' (#20) from JustAMacUser/tt-rss:fix-addhandler-notice into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/20
3 years ago
Andrew Dolgov f81a579386 fix selected feedtree item being invisible in dark theme 3 years ago
JustAMacUser 39bbbef030 Fix E_NOTICE in `add_handler()`. 3 years ago
Andrew Dolgov 1870fe172b feed tree: css cleanup; set cursor 3 years ago
Andrew Dolgov b23ba3e236 error log: fix column widths 3 years ago
Andrew Dolgov a0ce7f556b nsfw: set cursor pointer 3 years ago
Andrew Dolgov 1664b87821 Merge branch 'weblate-integration' 3 years ago
Andrew Dolgov 13210747d8 mailer: stop warning if to_name is unset (it's optional anyway) 3 years ago
Andrew Dolgov 15b39a534d Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov f7ee812db2 update editorconfig 3 years ago
fox 1b71cd9f44 Merge pull request 'Set orm and pdo mysql charset on connection' (#19) from Gravemind/tt-rss:fix-mysql-charset into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/19
3 years ago
Jordan Galby 3d801b1ac5 set orm and pdo mysql charset on connection 3 years ago
Andrew Dolgov 2f402d598d only show right-side feed icon for vfeeds 3 years ago
Andrew Dolgov 38ab3ef11c Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 4ddcd54e8d * limit progressfunction debugging to size quota exceeded notifications
* af_redditimgur: reparent generated iframes outside of post table
3 years ago
fox 06ebb81eb8 Merge pull request 'Add coalescing operator to otp_enabled when changing user password' (#18) from klempin/tt-rss:fix/undefined-array-key into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/18
3 years ago
Philip Klempin fa22e1bc35 Add coalescing operator to otp_enabled when changing user password 3 years ago
Andrew Dolgov 4e81233ac9 make description clickable in plugin list row 3 years ago
Andrew Dolgov fcce1c443e api: don't try to pass null site_url to Article::_get_image() 3 years ago
Andrew Dolgov bc73bf0f67 cdmToggleGridSpan: toggle classname instead of a style property 3 years ago
Andrew Dolgov efde6d36c7 add HOOK_HEADLINES_SCROLL_HANDLER 3 years ago
Andrew Dolgov e85cba5958 sticky header: better positioning strategy 3 years ago
Marek Pavelka f9d366f028 Translated using Weblate (Czech)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/cs/
3 years ago
Andrew Dolgov 52d1a5c96d gettextify previous 3 years ago
Andrew Dolgov 580eccd3da throttle login attempts, controlled by Config::AUTH_MIN_INTERVAL 3 years ago
Andrew Dolgov b9268fcc88 schema: add ttrss_users.last_auth_attempt 3 years ago
Andrew Dolgov 96d89fe912 shorten_expanded: reduce log spam 3 years ago
Andrew Dolgov bd1630d278 grid mode: limit word breaking to link elements 3 years ago
Andrew Dolgov 76a6060ca3 get_override_links: actually return overrides 3 years ago
Andrew Dolgov 4949e1a590 valid OTP code should not be enough to login, oops 3 years ago
Andrew Dolgov 146b1e0feb * shorten_expanded: use ResizeObserver (DUH)
* add HOOK_HEADLINES_RENDERED
3 years ago
Andrew Dolgov 6e0474a7c8 update zoom layout a bit 3 years ago
Andrew Dolgov f67d2623b7 add some media queries to improve main UI on small-width devices 3 years ago
Andrew Dolgov a4da2f1e62 continuation of the css cleanup 3 years ago
Andrew Dolgov 755072de91 css cleanup, combined mode, fonts 3 years ago
Andrew Dolgov de47082ca6 Article.cdmToggleGridSpan: also set as active 3 years ago
Andrew Dolgov f9a381ecca grid: add a header icon (and a hotkey) to toggle article span entire row 3 years ago
Andrew Dolgov 27ab16b6dc add Config::LOCAL_OVERRIDE_JS 3 years ago
Andrew Dolgov 324aef9f6f route Logger:log() to user_error() if there's no adapter 3 years ago
Andrew Dolgov 03361dda34 remove previous spacer-specific hack, not needed anymore 3 years ago
Andrew Dolgov 24e64b8c78 exp: set last odd grid child to span all columns 3 years ago
Andrew Dolgov 21e0b28cf1 nsfw plugin: we don't actually need any JS 3 years ago
Andrew Dolgov f9a9fcbb56 fix related to Promise.allSettled() returning a bit different result object 3 years ago
Andrew Dolgov 3e1b3e8ea8 grid: add workaround for a single loaded headline not spanning all columns 3 years ago
Andrew Dolgov 143617afb1 * it feels weird for requireIdleCallback() to be optional while more
modern browser features are required
 * simplify browser startup feature check a bit
3 years ago
Andrew Dolgov 84fe383ed4 adjust grid view footer (3) 3 years ago
Andrew Dolgov 16726ec07f adjust grid view footer (2) 3 years ago
Andrew Dolgov a0dd5baa51 adjust grid view footer 3 years ago
Andrew Dolgov 5e738ec278 shorten stuff in af_zz_vidmute 3 years ago
Andrew Dolgov 71b12857e0 in grid mode, also force word-break .intermediate (enclosures) 3 years ago
Andrew Dolgov 353ee40378 shorten_expanded: remove loading=lazy on the js side instead 3 years ago
Andrew Dolgov 668b0ac7a6 shorten_expanded: no need to hook on HOOK_SANITIZE anymore 3 years ago
Andrew Dolgov fb89c3bad0 instead of a fixed column layout, fit based on minimum column size 3 years ago
Andrew Dolgov 5bc47451e1 shorten_expanded: increase timeout 3 years ago
Andrew Dolgov 36ad46e60d * shorten_expanded: use promises instead of a timeout hack
* normalize some icon colors
3 years ago
Ptsa Daniel 02af69328f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (655 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
3 years ago
Dario Di Ludovico 4aa595e3ba Translated using Weblate (Italian)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/it/
3 years ago
Andrew Dolgov 96031c80bf stop setting specific background color on .cdm.expanded 3 years ago
Andrew Dolgov e826c9e055 fix crash in preferences due to headlines-frame missing 3 years ago
Andrew Dolgov f58879c1dc small stuck header fixes in grid mode 3 years ago
Andrew Dolgov bdc72e5b63 fix headlines-spacer height in grid mode 3 years ago
Andrew Dolgov df9c389cbf in grid mode, hide feed title from header 3 years ago
Andrew Dolgov b6033d0bbd grid view tweaks 3 years ago
Andrew Dolgov 0b93d8d013 add hotkey to toggle grid view 3 years ago
Andrew Dolgov 089fa5ec26 use proper syntax for equal-width columns 3 years ago
Andrew Dolgov 87d13e826f fix vfeed group subtitle in grid mode 3 years ago
Andrew Dolgov eba8c97f36 some minor grid stuff 3 years ago
Andrew Dolgov a3ab4020bf set #headlines-spacer, etc, to span grid columns 3 years ago
Andrew Dolgov ddfa39015e experimental: add preference to show combined mode headlines as a 2 column grid 3 years ago
Andrew Dolgov 6ec66d0ce5 set border color on that too 3 years ago
Andrew Dolgov f804caec90 support coloring counters by feed-id/is-cat; set fresh counter to green 3 years ago
Andrew Dolgov ae7b87bca9 add HOOK_HEADLINE_MUTATIONS, HOOK_HEADLINE_MUTATIONS_SYNCED 3 years ago
Andrew Dolgov 2160a86092 show E_COMPILE_ERROR in event log at higher severity levels 3 years ago
Andrew Dolgov 4e1c78374f error log: allow wrapping long filenames 3 years ago
Andrew Dolgov 74391ec30a reorganize update.php a bit, remove unneeded options 3 years ago
Andrew Dolgov dd9d017f7d add another coalesce for rule inverse 3 years ago
Andrew Dolgov 9b321be270 get_article_filters: set coalesce values for inverse and match_any_rule 3 years ago
Andrew Dolgov 4fe2e6bbf1 app password list: fix th/td alignment 3 years ago
Andrew Dolgov b1961163b8 af_redditimgur: import link flair as tags 3 years ago
Andrew Dolgov bc7cb76379 describe global settings in classes/config.php 3 years ago
Weblate 63ca6333a5 Update translation files
Updated by "Update PO files to match POT (msgmerge)" hook in Weblate.

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/
3 years ago
Andrew Dolgov ea25c49eb9 update messages.pot 3 years ago
Andrew Dolgov fe4c284858 Merge branch 'weblate-integration' 3 years ago
fox 9b2267510b Merge pull request 'Default to null 'rv' for plugin update check.' (#17) from wn/tt-rss:inaccurate-available-plugin-updates into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/17
3 years ago
wn_ fed5158ec5 Default to null 'rv' for plugin update check.
Previously 'rv' was returned as an empty JS array, causing 'p.rv.git_status != 0' to evaluate to true and a misleading 'Ready to update' appearing for certain plugins.
3 years ago
Andrew Dolgov cfb4882591 cleanup javascript_tag and stylesheet_tag 3 years ago
Andrew Dolgov 28dd255c30 show user css editor before xhr is completed 3 years ago
Andrew Dolgov bfeaf4d6a4 search dialog: add button icon 3 years ago
Andrew Dolgov ef03f8188c api: add support for setting score (bump api level to 16) 3 years ago
Andrew Dolgov c26f58d8a5 fix some php8 warnings 3 years ago
Andrew Dolgov a125e8540d Merge branch 'master' of git.fakecake.org:fox/tt-rss 3 years ago
Andrew Dolgov 1fb7125f90 minor cleanup related to toolbar-main (use dijit methods, etc) 3 years ago
Andrew Dolgov 46b77fc6b7 fix digest preview not working on mysql because of a quoted LIMIT argument 3 years ago
Andrew Dolgov 5db6939dc9 add to previous a bit 3 years ago
Andrew Dolgov 603cc89638 check updates one plugin at a time 3 years ago
Andrew Dolgov f4d0e7bb6d * af_redditimgur: optionally import score
* add pluginhost->set_array() to set many plugin settings at once
3 years ago
Andrew Dolgov 72c04123d4 HOOK_ARTICLE_IMAGE: stop after first provided match 3 years ago
Andrew Dolgov 518e677a6b nsfw: fix wrong return parameter count in hook article image 3 years ago
Andrew Dolgov 266c8a6eae add nsfw.png placeholder 3 years ago
Andrew Dolgov ac6a59914b nsfw: support API clients 3 years ago
Andrew Dolgov ffb93d72ac fix previous to actually save enabled plugins 3 years ago
Andrew Dolgov 773bad1490 prevent list of enabled plugins resetting if saved while in search results 3 years ago
Andrew Dolgov 1dcc36deca make rendered labels clickable 3 years ago
Andrew Dolgov c036c27ec7 logger: use constants instead of hardcoded string literals 3 years ago
Andrew Dolgov 17650775d2 hide event log accordion pane if LOG_DESTINATION is not sql 3 years ago
Andrew Dolgov 5bb8714839 allow blank override values 3 years ago
Andrew Dolgov 77b5201b7d set plugin list name width same as preferences table 3 years ago
Andrew Dolgov d6fd0d5462 add some icons, remove some words 3 years ago
Andrew Dolgov 39c570a9ff Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov b27218a1e3 add some more dialog icons 3 years ago
fox cb81b784e8 Merge pull request 'Fix "array offset on value of type null" for $error and $old_error' (#16) from ltGuillaume/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/16
3 years ago
Andrew Dolgov 1d9fa2a42e reduce overhead in hash set/get 3 years ago
ltGuillaume 825e362f0e Fix "array offset on value of type null" for $error and $old_error
I tried applying to only $error and only $old_error, but both appear to be needed.

Log entries:
E_NOTICE (8) 	classes/urlhelper.php:464 	Trying to access array offset on value of type null
1. classes/urlhelper.php(464): ttrss_error_handler(8, Trying to access array offset on value of type null, classes/urlhelper.php, 464, [)
2. classes/rssutils.php(464): fetch([{"url":"https://some.url.rss","login":"","pass":"","timeout":15,"last_modified":"Sat, 31 Aug 2019 15:22:31 GMT"})
3. update.php(235): update_rss_feed(732, 1)
3 years ago
Andrew Dolgov 7b0b5b55c7 fix plugins-list line height 3 years ago
Andrew Dolgov 68ecf52594 some small layout fixes, remove a few inline styles 3 years ago
Andrew Dolgov 473ea6255c render list of plugins on the client 3 years ago
Andrew Dolgov 217922899d set some more type hints 3 years ago
Andrew Dolgov 270f0c3132 general cleanup, set some type hints 3 years ago
Andrew Dolgov 63651bd91d fix some leftover variables 3 years ago
Andrew Dolgov e5469479c1 * don't try to update custom set feed favicons
* cleanup update_rss_feed() a bit, use ORM
3 years ago
Andrew Dolgov 42e057c808 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 53dcd4b229 fix plugins not shown as already installed if they have more than 1 dash 3 years ago
fox 42cb2e5112 Merge pull request 'The type hint for 'DAEMON_MAX_CHILD_RUNTIME' should be T_INT' (#15) from wn/tt-rss:deamon-max-child-runtime-type-hint into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/15
3 years ago
wn_ 2e8b064236 The type hint for 'DAEMON_MAX_CHILD_RUNTIME' should be T_INT 3 years ago
Andrew Dolgov 2cd159e2ce use separate database column for OTP secrets (migrate previous format if needed) 3 years ago
Andrew Dolgov 2aed79d729 schema: add separate otp_secret column 3 years ago
Andrew Dolgov ecb94ec23d login page: fix a warning if return is unset 3 years ago
Andrew Dolgov 5c1f9f31bd add a bunch of button icons 3 years ago
Andrew Dolgov fe06416f17 sessions: stop validating against hash of user agent because chromium is sending
different agent headers for whatever reason, example:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
like Gecko) Chrome/88.0.4324.192 Safari/537.36

Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like
Gecko) Chrome/88.0.4324.104 Safari/537.36

seems to be related, at least, to App.postOpenWindow() hack.
3 years ago
Andrew Dolgov 98c75a9e43 don't check for plugin updates automatically on pane open 3 years ago
Andrew Dolgov b649d2240f split af_zz_noautoplay into a separate repo 3 years ago
Andrew Dolgov c8883d3440 af_comics filters: don't try to load empty html 3 years ago
Andrew Dolgov bc2953b5e7 split no_url_hashes into a separate repo 3 years ago
Andrew Dolgov 198c9b4069 split scored_oldest_first into a separate repo 3 years ago
Andrew Dolgov e8e6329040 rename unfairly prefixed get_enclosures() in feeditem 3 years ago
Andrew Dolgov c744cfe2dc plugin installer: show last commit timestamp 3 years ago
Andrew Dolgov d016f7a499 Merge branch 'master' of git.tt-rss.org:fox/tt-rss 3 years ago
Andrew Dolgov 476965b161 show installed plugins in the installer list 3 years ago
fox c9b0196de0 Merge pull request 'Fix Undefined index when using Single User Mode' (#14) from Threk/tt-rss:master into master
Reviewed-on: https://git.tt-rss.org/fox/tt-rss/pulls/14
3 years ago
Threk 9442ceb7bd Fix Undefined index when using Single User Mode 3 years ago
Andrew Dolgov f398fea414 shorten plugin list action buttons 3 years ago
Andrew Dolgov cb4b730e42 split af_unburn 3 years ago
Andrew Dolgov 386dc415d9 a bit better search behavior for plugin installer 3 years ago
Andrew Dolgov 9b8b07376f shorten install button text 3 years ago
Andrew Dolgov f90531ae40 reduce plugin installer entry height 3 years ago
Andrew Dolgov 6cf771f2bc _get_available_plugins: decode as array 3 years ago
Andrew Dolgov c50a4296a5 split vf_shared 3 years ago
Andrew Dolgov 04128c7870 add search to plugin installer 3 years ago
Andrew Dolgov 2f6ea8b387 split a bunch of plugins into separate repos 3 years ago
Andrew Dolgov b74e313844 use computed style for element.prototype.visible 3 years ago
Andrew Dolgov 4fda5ccd0e fix a bunch of bookmarklets login forms not leading back 3 years ago
Andrew Dolgov 30765805fd use orm for settings profiles stuff 3 years ago
Andrew Dolgov 31b29e0a56 log applied migrations 3 years ago
Andrew Dolgov 8f8ca49e4b migrations: refuse to apply empty schema files 3 years ago
Andrew Dolgov 4ede76280b migrations: don't try to use transactions on mysql 3 years ago
Andrew Dolgov bd4ade6329 remove ttrss_version from base schema 3 years ago
Andrew Dolgov 5eb0f3d640 bring back web dbupdate using new migrations system 3 years ago
Andrew Dolgov e19570f422 sessions: don't check schema version 3 years ago
Andrew Dolgov c0fb0a5ec0 wip for db_migrations for core schema 3 years ago
Andrew Dolgov 921569e5da support loading base schema as latest version 3 years ago
Andrew Dolgov 8256ab5dd9 wip: initial for db_migrations 3 years ago
Andrew Dolgov 0cb719a404 add basic local plugin uninstaller 3 years ago
Marek Pavelka 5c6c123676 Translated using Weblate (Czech)
Currently translated at 100.0% (660 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/cs/
3 years ago
Andrew Dolgov dfdb746a76 add word wrap for git stdout/stderr pre elements 3 years ago
Andrew Dolgov cb7f322f09 add basic plugin installer (uses tt-rss.org) 3 years ago
Ptsa Daniel 5832b0b040 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (655 of 660 strings)

Translation: Tiny Tiny RSS/messages
Translate-URL: https://weblate.tt-rss.org/projects/tt-rss/messages/zh_Hans/
3 years ago

@ -4,3 +4,6 @@ insert_final_newline = true
[*.php]
indent_style = tab
[*.js]
indent_style = tab

@ -20,6 +20,3 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Copyright (c) 2005 Andrew Dolgov (unless explicitly stated otherwise).
Uses Silk icons by Mark James: http://www.famfamfam.com/lab/icons/silk/

@ -2,7 +2,7 @@
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
get_include_path());
$op = $_REQUEST["op"];
$op = $_REQUEST['op'] ?? '';
$method = !empty($_REQUEST['subop']) ?
$_REQUEST['subop'] :
$_REQUEST["method"] ?? false;
@ -51,7 +51,7 @@
UserHelper::load_user_plugins($_SESSION["uid"]);
}
if (Db_Updater::is_update_required()) {
if (Config::is_migration_needed()) {
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
return;
}
@ -161,6 +161,6 @@
}
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$_REQUEST["op"]) . "->$method"]);
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
?>

@ -1,7 +1,7 @@
<?php
class API extends Handler {
const API_LEVEL = 15;
const API_LEVEL = 17;
const STATUS_OK = 0;
const STATUS_ERR = 1;
@ -74,7 +74,12 @@ class API extends Handler {
if ($uid = UserHelper::find_user_by_login($login)) {
if (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
// needed for _get_config()
UserHelper::load_user_plugins($_SESSION['uid']);
$this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
"config" => $this->_get_config(),
"api_level" => self::API_LEVEL));
} else {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
@ -132,49 +137,48 @@ class API extends Handler {
// TODO do not return empty categories, return Uncategorized and standard virtual cats
if ($enable_nested)
$nested_qpart = "parent_cat IS NULL";
else
$nested_qpart = "true";
$categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title', 'order_id')
->select_many_expr([
'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)',
'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)',
])
->where('owner_uid', $_SESSION['uid']);
$sth = $this->pdo->prepare("SELECT
id, title, order_id, (SELECT COUNT(id) FROM
ttrss_feeds WHERE
ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id) AS num_feeds,
(SELECT COUNT(id) FROM
ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_cats
FROM ttrss_feed_categories
WHERE $nested_qpart AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
if ($enable_nested) {
$categories->where_null('parent_cat');
}
$cats = array();
$cats = [];
while ($line = $sth->fetch()) {
if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) {
$unread = getFeedUnread($line["id"], true);
foreach ($categories->find_many() as $category) {
if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) {
$unread = getFeedUnread($category->id, true);
if ($enable_nested)
$unread += Feeds::_get_cat_children_unread($line["id"]);
$unread += Feeds::_get_cat_children_unread($category->id);
if ($unread || !$unread_only) {
array_push($cats, array("id" => (int) $line["id"],
"title" => $line["title"],
"unread" => (int) $unread,
"order_id" => (int) $line["order_id"],
));
array_push($cats, [
'id' => (int) $category->id,
'title' => $category->title,
'unread' => (int) $unread,
'order_id' => (int) $category->order_id,
]);
}
}
}
foreach (array(-2,-1,0) as $cat_id) {
foreach ([-2,-1,0] as $cat_id) {
if ($include_empty || !$this->_is_cat_empty($cat_id)) {
$unread = getFeedUnread($cat_id, true);
if ($unread || !$unread_only) {
array_push($cats, array("id" => $cat_id,
"title" => Feeds::_get_cat_title($cat_id),
"unread" => (int) $unread));
array_push($cats, [
'id' => $cat_id,
'title' => Feeds::_get_cat_title($cat_id),
'unread' => (int) $unread,
]);
}
}
}
@ -258,6 +262,10 @@ class API extends Handler {
break;
case 3:
$field = "note";
break;
case 4:
$field = "score";
break;
};
switch ($mode) {
@ -273,6 +281,7 @@ class API extends Handler {
}
if ($field == "note") $set_to = $this->pdo->quote($data);
if ($field == "score") $set_to = (int) $data;
if ($field && $set_to && count($article_ids) > 0) {
@ -295,60 +304,59 @@ class API extends Handler {
}
function getArticle() {
$article_ids = explode(',', clean($_REQUEST['article_id'] ?? ''));
$sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true);
$article_ids = explode(",", clean($_REQUEST["article_id"]));
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
self::_param_to_bool($_REQUEST["sanitize"]);
if (count($article_ids) > 0) {
$article_qmarks = arr_qmarks($article_ids);
$sth = $this->pdo->prepare("SELECT id,guid,title,link,content,feed_id,comments,int_id,
marked,unread,published,score,note,lang,
".SUBSTRING_FOR_DATE."(updated,1,16) as updated,
author,(SELECT title FROM ttrss_feeds WHERE id = feed_id) AS feed_title,
(SELECT site_url FROM ttrss_feeds WHERE id = feed_id) AS site_url,
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images
FROM ttrss_entries,ttrss_user_entries
WHERE id IN ($article_qmarks) AND ref_id = id AND owner_uid = ?");
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
$articles = array();
while ($line = $sth->fetch()) {
$article = array(
"id" => $line["id"],
"guid" => $line["guid"],
"title" => $line["title"],
"link" => $line["link"],
"labels" => Article::_get_labels($line['id']),
"unread" => self::_param_to_bool($line["unread"]),
"marked" => self::_param_to_bool($line["marked"]),
"published" => self::_param_to_bool($line["published"]),
"comments" => $line["comments"],
"author" => $line["author"],
"updated" => (int) strtotime($line["updated"]),
"feed_id" => $line["feed_id"],
"attachments" => Article::_get_enclosures($line['id']),
"score" => (int)$line["score"],
"feed_title" => $line["feed_title"],
"note" => $line["note"],
"lang" => $line["lang"]
);
// @phpstan-ignore-next-line
if (count($article_ids)) {
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments',
'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note')
->select_many_expr([
'updated' => SUBSTRING_FOR_DATE.'(updated,1,16)',
'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)',
'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)',
'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)',
])
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', array_map('intval', $article_ids))
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$articles = [];
foreach ($entries as $entry) {
$article = [
'id' => $entry->id,
'guid' => $entry->guid,
'title' => $entry->title,
'link' => $entry->link,
'labels' => Article::_get_labels($entry->id),
'unread' => self::_param_to_bool($entry->unread),
'marked' => self::_param_to_bool($entry->marked),
'published' => self::_param_to_bool($entry->published),
'comments' => $entry->comments,
'author' => $entry->author,
'updated' => (int) strtotime($entry->updated),
'feed_id' => $entry->feed_id,
'attachments' => Article::_get_enclosures($entry->id),
'score' => (int) $entry->score,
'feed_title' => $entry->feed_title,
'note' => $entry->note,
'lang' => $entry->lang,
];
if ($sanitize_content) {
$article["content"] = Sanitizer::sanitize(
$line["content"],
self::_param_to_bool($line['hide_images']),
false, $line["site_url"], false, $line["id"]);
$article['content'] = Sanitizer::sanitize(
$entry->content,
self::_param_to_bool($entry->hide_images),
false, $entry->site_url, false, $entry->id);
} else {
$article["content"] = $line["content"];
$article['content'] = $entry->content;
}
$hook_object = ["article" => &$article];
$hook_object = ['article' => &$article];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
function ($result) use (&$article) {
@ -359,30 +367,32 @@ class API extends Handler {
$article['content'] = DiskCache::rewrite_urls($article['content']);
array_push($articles, $article);
}
$this->_wrap(self::STATUS_OK, $articles);
// @phpstan-ignore-next-line
} else {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
$this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]);
}
}
function getConfig() {
private function _get_config() {
$config = [
"icons_dir" => Config::get(Config::ICONS_DIR),
"icons_url" => Config::get(Config::ICONS_URL)
];
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
$config["custom_sort_types"] = $this->_get_custom_sort_types();
$config["num_feeds"] = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count();
$sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $config;
}
$config["num_feeds"] = $row["cf"];
function getConfig() {
$config = $this->_get_config();
$this->_wrap(self::STATUS_OK, $config);
}
@ -417,36 +427,36 @@ class API extends Handler {
}
function getLabels() {
$article_id = (int)clean($_REQUEST['article_id']);
$article_id = (int)clean($_REQUEST['article_id'] ?? -1);
$rv = array();
$rv = [];
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color
FROM ttrss_labels2
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$_SESSION['uid']]);
$labels = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('caption')
->find_many();
if ($article_id)
$article_labels = Article::_get_labels($article_id);
else
$article_labels = array();
while ($line = $sth->fetch()) {
$article_labels = [];
foreach ($labels as $label) {
$checked = false;
foreach ($article_labels as $al) {
if (Labels::feed_to_label_id($al[0]) == $line['id']) {
if (Labels::feed_to_label_id($al[0]) == $label->id) {
$checked = true;
break;
}
}
array_push($rv, array(
"id" => (int)Labels::label_to_feed_id($line['id']),
"caption" => $line['caption'],
"fg_color" => $line['fg_color'],
"bg_color" => $line['bg_color'],
"checked" => $checked));
array_push($rv, [
'id' => (int) Labels::label_to_feed_id($label->id),
'caption' => $label->caption,
'fg_color' => $label->fg_color,
'bg_color' => $label->bg_color,
'checked' => $checked,
]);
}
$this->_wrap(self::STATUS_OK, $rv);
@ -507,10 +517,7 @@ class API extends Handler {
}
private static function _api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
$feeds = array();
$pdo = Db::pdo();
$feeds = [];
$limit = (int) $limit;
$offset = (int) $offset;
@ -523,17 +530,15 @@ class API extends Handler {
$counters = Counters::get_labels();
foreach (array_values($counters) as $cv) {
$unread = $cv["counter"];
$unread = $cv['counter'];
if ($unread || !$unread_only) {
$row = array(
"id" => (int) $cv["id"],
"title" => $cv["description"],
"unread" => $cv["counter"],
"cat_id" => -2,
);
$row = [
'id' => (int) $cv['id'],
'title' => $cv['description'],
'unread' => $cv['counter'],
'cat_id' => -2,
];
array_push($feeds, $row);
}
@ -543,45 +548,45 @@ class API extends Handler {
/* Virtual feeds */
if ($cat_id == -4 || $cat_id == -1) {
foreach (array(-1, -2, -3, -4, -6, 0) as $i) {
foreach ([-1, -2, -3, -4, -6, 0] as $i) {
$unread = getFeedUnread($i);
if ($unread || !$unread_only) {
$title = Feeds::_get_title($i);
$row = array(
"id" => $i,
"title" => $title,
"unread" => $unread,
"cat_id" => -1,
);
$row = [
'id' => $i,
'title' => $title,
'unread' => $unread,
'cat_id' => -1,
];
array_push($feeds, $row);
}
}
}
/* Child cats */
if ($include_nested && $cat_id) {
$sth = $pdo->prepare("SELECT
id, title, order_id FROM ttrss_feed_categories
WHERE parent_cat = ? AND owner_uid = ? ORDER BY order_id, title");
$sth->execute([$cat_id, $_SESSION['uid']]);
$categories = ORM::for_table('ttrss_feed_categories')
->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"], true) +
Feeds::_get_cat_children_unread($line["id"]);
foreach ($categories as $category) {
$unread = getFeedUnread($category->id, true) +
Feeds::_get_cat_children_unread($category->id);
if ($unread || !$unread_only) {
$row = array(
"id" => (int) $line["id"],
"title" => $line["title"],
"unread" => $unread,
"is_cat" => true,
"order_id" => (int) $line["order_id"]
);
$row = [
'id' => (int) $category->id,
'title' => $category->title,
'unread' => $unread,
'is_cat' => true,
'order_id' => (int) $category->order_id,
];
array_push($feeds, $row);
}
}
@ -589,51 +594,36 @@ class API extends Handler {
/* Real feeds */
if ($limit) {
$limit_qpart = "LIMIT $limit OFFSET $offset";
} else {
$limit_qpart = "";
}
/* API only: -3 All feeds, excluding virtual feeds (e.g. Labels and such) */
if ($cat_id == -4 || $cat_id == -3) {
$sth = $pdo->prepare("SELECT
id, feed_url, cat_id, title, order_id, ".
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE owner_uid = ?
ORDER BY order_id, title " . $limit_qpart);
$sth->execute([$_SESSION['uid']]);
} else {
$sth = $pdo->prepare("SELECT
id, feed_url, cat_id, title, order_id, ".
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))
AND owner_uid = :uid
ORDER BY order_id, title " . $limit_qpart);
$sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]);
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('order_id')
->order_by_asc('title');
if ($limit) $feeds_obj->limit($limit);
if ($offset) $feeds_obj->offset($offset);
if ($cat_id != -3 && $cat_id != -4) {
$feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]);
}
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"]);
$has_icon = Feeds::_has_icon($line['id']);
foreach ($feeds_obj->find_many() as $feed) {
$unread = getFeedUnread($feed->id);
$has_icon = Feeds::_has_icon($feed->id);
if ($unread || !$unread_only) {
$row = array(
"feed_url" => $line["feed_url"],
"title" => $line["title"],
"id" => (int)$line["id"],
"unread" => (int)$unread,
"has_icon" => $has_icon,
"cat_id" => (int)$line["cat_id"],
"last_updated" => (int) strtotime($line["last_updated"]),
"order_id" => (int) $line["order_id"],
);
$row = [
'feed_url' => $feed->feed_url,
'title' => $feed->title,
'id' => (int) $feed->id,
'unread' => (int) $unread,
'has_icon' => $has_icon,
'cat_id' => (int) $feed->cat_id,
'last_updated' => (int) strtotime($feed->last_updated),
'order_id' => (int) $feed->order_id,
];
array_push($feeds, $row);
}
@ -648,26 +638,24 @@ class API extends Handler {
$search = "", $include_nested = false, $sanitize_content = true,
$force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) {
$pdo = Db::pdo();
if ($force_update && $feed_id > 0 && is_numeric($feed_id)) {
// Update the feed if required with some basic flood control
$sth = $pdo->prepare(
"SELECT cache_images,".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE id = ?");
$sth->execute([$feed_id]);
$feed = ORM::for_table('ttrss_feeds')
->select_many('id', 'cache_images')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->find_one($feed_id);
if ($row = $sth->fetch()) {
$last_updated = strtotime($row["last_updated"]);
$cache_images = self::_param_to_bool($row["cache_images"]);
if ($feed) {
$last_updated = strtotime($feed->last_updated);
$cache_images = self::_param_to_bool($feed->cache_images);
if (!$cache_images && time() - $last_updated > 120) {
RSSUtils::update_rss_feed($feed_id, true);
} else {
$sth = $pdo->prepare("UPDATE ttrss_feeds SET last_updated = '1970-01-01', last_update_started = '1970-01-01'
WHERE id = ?");
$sth->execute([$feed_id]);
$feed->last_updated = '1970-01-01';
$feed->last_update_started = '1970-01-01';
$feed->save();
}
}
}
@ -787,7 +775,8 @@ class API extends Handler {
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
$line["content"], // unsanitized
$line["site_url"]);
$line["site_url"] ?? "", // could be null if archived article
$headline_row);
$headline_row["flavor_image"] = $flavor_image;
$headline_row["flavor_stream"] = $flavor_stream;
@ -817,15 +806,15 @@ class API extends Handler {
function unsubscribeFeed() {
$feed_id = (int) clean($_REQUEST["feed_id"]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
$feed_exists = ORM::for_table('ttrss_feeds')
->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']])
->count();
if ($row = $sth->fetch()) {
Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
$this->_wrap(self::STATUS_OK, array("status" => "OK"));
if ($feed_exists) {
Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']);
$this->_wrap(self::STATUS_OK, ['status' => 'OK']);
} else {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
$this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]);
}
}
@ -858,27 +847,33 @@ class API extends Handler {
// only works for labels or uncategorized for the time being
private function _is_cat_empty($id) {
if ($id == -2) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $row["count"] == 0;
$label_count = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->count();
return $label_count == 0;
} else if ($id == 0) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_feeds
WHERE cat_id IS NULL AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $row["count"] == 0;
$uncategorized_count = ORM::for_table('ttrss_feeds')
->where_null('cat_id')
->where('owner_uid', $_SESSION['uid'])
->count();
return $uncategorized_count == 0;
}
return false;
}
private function _get_custom_sort_types() {
$ret = [];
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) {
foreach ($result as $sort_value => $sort_title) {
$ret[$sort_value] = $sort_title;
}
});
return $ret;
}
}

@ -543,17 +543,20 @@ class Article extends Handler_Protected {
return $rv;
}
static function _get_image($enclosures, $content, $site_url) {
static function _get_image(array $enclosures, string $content, string $site_url, array $headline) {
$article_image = "";
$article_stream = "";
$article_kind = 0;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE,
function ($result) use (&$article_image, &$article_stream, &$content) {
function ($result, $plugin) use (&$article_image, &$article_stream, &$content) {
list ($article_image, $article_stream, $content) = $result;
// run until first hard match
return !empty($article_image);
},
$enclosures, $content, $site_url);
$enclosures, $content, $site_url, $headline);
if (!$article_image && !$article_stream) {
$tmpdoc = new DOMDocument();

@ -6,7 +6,22 @@ class Config {
const T_STRING = 2;
const T_INT = 3;
// override defaults, defined below in _DEFAULTS[], via environment: DB_TYPE becomes TTRSS_DB_TYPE, etc
const SCHEMA_VERSION = 145;
/* override defaults, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
DB_TYPE becomes:
.env:
TTRSS_DB_TYPE=pgsql
or config.php:
putenv('TTRSS_DB_TYPE=pgsql');
etc, etc.
*/
const DB_TYPE = "DB_TYPE";
const DB_HOST = "DB_HOST";
@ -14,46 +29,151 @@ class Config {
const DB_NAME = "DB_NAME";
const DB_PASS = "DB_PASS";
const DB_PORT = "DB_PORT";
// database credentials
const MYSQL_CHARSET = "MYSQL_CHARSET";
// connection charset for MySQL. if you have a legacy database and/or experience
// garbage unicode characters with this option, try setting it to a blank string.
const SELF_URL_PATH = "SELF_URL_PATH";
// this should be set to a fully qualified URL used to access
// your tt-rss instance over the net, such as: https://example.com/tt-rss/
// if your tt-rss instance is behind a reverse proxy, use external URL.
// tt-rss will likely help you pick correct value for this on startup
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
// operate in single user mode, disables all functionality related to
// multiple users and authentication. enabling this assumes you have
// your tt-rss directory protected by other means (e.g. http auth).
const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE";
// enables fallback update mode where tt-rss tries to update feeds in
// background while tt-rss is open in your browser.
// if you don't have a lot of feeds and don't want to or can't run
// background processes while not running tt-rss, this method is generally
// viable to keep your feeds up to date.
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
// use this PHP CLI executable to start various tasks
const LOCK_DIRECTORY = "LOCK_DIRECTORY";
// base directory for lockfiles (must be writable)
const CACHE_DIR = "CACHE_DIR";
// base directory for local cache (must be writable)
const ICONS_DIR = "ICONS_DIR";
const ICONS_URL = "ICONS_URL";
// directory and URL for feed favicons (directory must be writable)
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
// auto create users authenticated via external modules
const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN";
// auto log in users authenticated via external modules i.e. auth_remote
const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE";
// unconditinally purge all articles older than this amount, in days
// overrides user-controlled purge interval
const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME";
// default lifetime of a session (e.g. login) cookie. In seconds,
// 0 means cookie will be deleted when browser closes.
const SMTP_FROM_NAME = "SMTP_FROM_NAME";
const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS";
// send email using this name and address
const DIGEST_SUBJECT = "DIGEST_SUBJECT";
// default subject for email digest
const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES";
// enable built-in update checker, both for core code and plugins (using git)
const PLUGINS = "PLUGINS";
// system plugins enabled for all users, comma separated list, no quotes
// keep at least one auth module in there (i.e. auth_internal)
const LOG_DESTINATION = "LOG_DESTINATION";
// available options: sql (default, event log), syslog, stdout (for debugging)
const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET";
// link this stylesheet on all pages (if it exists), should be placed in themes.local
const LOCAL_OVERRIDE_JS = "LOCAL_OVERRIDE_JS";
// same but this javascript file (you can use that for polyfills), should be placed in themes.local
const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME";
// in seconds, terminate update tasks that ran longer than this interval
const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS";
// max concurrent update jobs forking update daemon starts
const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT";
// How long to wait for response when requesting feed from a site (seconds)
const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT";
// Same but not cached
const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT";
// Default timeout when fetching files from remote sites
const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT";
// How long to wait for initial response from website when fetching files from remote sites
const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT";
// stop updating feeds if user haven't logged in for X days
const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT";
// how many feeds to update in one batch
const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL";
// default sleep interval between feed updates (sec)
const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE";
// do not cache files larger than that (bytes)
const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE";
// do not download files larger than that (bytes)
const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE";
// max file size for downloaded favicons (bytes)
const CACHE_MAX_DAYS = "CACHE_MAX_DAYS";
// max age in days for various automatically cached (temporary) files
const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL";
// max interval between forced unconditional updates for servers not complying with http if-modified-since (seconds)
const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT";
// automatically disable updates for feeds which failed to
// update for this amount of days; 0 disables
const LOG_SENT_MAIL = "LOG_SENT_MAIL";
// log all sent emails in the event log
const HTTP_PROXY = "HTTP_PROXY";
// use HTTP proxy for requests
const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES";
// prevent users from changing passwords
const SESSION_NAME = "SESSION_NAME";
// default session cookie name
const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES";
// enable plugin update checker (using git)
const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER";
// allow installing first party plugins using plugin installer in prefs
const AUTH_MIN_INTERVAL = "AUTH_MIN_INTERVAL";
// minimum amount of seconds required between authentication attempts
const HTTP_USER_AGENT = "HTTP_USER_AGENT";
// http user agent (changing this is not recommended)
// default values for all of the above:
private const _DEFAULTS = [
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
Config::DB_HOST => [ "db", Config::T_STRING ],
@ -80,10 +200,12 @@ class Config {
Config::T_STRING ],
Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ],
Config::PLUGINS => [ "auth_internal", Config::T_STRING ],
Config::LOG_DESTINATION => [ "sql", Config::T_STRING ],
Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ],
Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
Config::T_STRING ],
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_STRING ],
Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js",
Config::T_STRING ],
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ],
Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ],
Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ],
Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ],
@ -102,6 +224,11 @@ class Config {
Config::HTTP_PROXY => [ "", Config::T_STRING ],
Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ],
Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ],
Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ],
Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ],
Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ],
Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)',
Config::T_STRING ],
];
private static $instance;
@ -110,6 +237,9 @@ class Config {
private $schema_version = null;
private $version = [];
/** @var Db_Migrations $migrations */
private $migrations;
public static function get_instance() : Config {
if (self::$instance == null)
self::$instance = new self();
@ -130,7 +260,7 @@ class Config {
list ($defval, $deftype) = $this::_DEFAULTS[$const];
$this->params[$cvalue] = [ self::cast_to(!empty($override) ? $override : $defval, $deftype), $deftype ];
$this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ];
}
}
}
@ -214,18 +344,25 @@ class Config {
return $rv;
}
static function get_schema_version(bool $nocache = false) {
return self::get_instance()->_schema_version($nocache);
static function get_migrations() : Db_Migrations {
return self::get_instance()->_get_migrations();
}
function _schema_version(bool $nocache = false) {
if (empty($this->schema_version) || $nocache) {
$row = Db::pdo()->query("SELECT schema_version FROM ttrss_version")->fetch();
$this->schema_version = (int) $row["schema_version"];
private function _get_migrations() : Db_Migrations {
if (empty($this->migrations)) {
$this->migrations = new Db_Migrations();
$this->migrations->initialize(dirname(__DIR__) . "/sql", "ttrss_version", true, self::SCHEMA_VERSION);
}
return $this->schema_version;
return $this->migrations;
}
static function is_migration_needed() : bool {
return self::get_migrations()->is_migration_needed();
}
static function get_schema_version() : int {
return self::get_migrations()->get_version();
}
static function cast_to(string $value, int $type_hint) {
@ -248,7 +385,7 @@ class Config {
private function _add(string $param, string $default, int $type_hint) {
$override = getenv($this::_ENVVAR_PREFIX . $param);
$this->params[$param] = [ self::cast_to(!empty($override) ? $override : $default, $type_hint), $type_hint ];
$this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ];
}
static function add(string $param, string $default, int $type_hint = Config::T_STRING) {
@ -322,7 +459,7 @@ class Config {
$pdo = Db::pdo();
$errors = array();
$errors = [];
if (strpos(self::get(Config::PLUGINS), "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via PLUGINS");
@ -352,13 +489,19 @@ class Config {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
}
if (self::get(Config::SINGLE_USER_MODE) && class_exists("PDO")) {
// ttrss_users won't be there on initial startup (before migrations are done)
if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) {
if (UserHelper::get_login_by_id(1) != "admin") {
array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found.");
}
}
if (php_sapi_name() != "cli") {
if (self::get_schema_version() < 0) {
array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (<code>update.php --update-schema</code>)");
}
$ref_self_url_path = self::make_self_url();
if ($ref_self_url_path) {
@ -482,4 +625,20 @@ class Config {
private static function format_error($msg) {
return "<div class=\"alert alert-danger\">$msg</div>";
}
static function get_override_links() {
$rv = "";
$local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET));
if ($local_css) $rv .= stylesheet_tag($local_css);
$local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS));
if ($local_js) $rv .= javascript_tag($local_js);
return $rv;
}
static function get_user_agent() {
return sprintf(self::get(self::HTTP_USER_AGENT), self::get_version());
}
}

@ -14,6 +14,9 @@ class Db
ORM::configure('username', Config::get(Config::DB_USER));
ORM::configure('password', Config::get(Config::DB_PASS));
ORM::configure('return_result_sets', true);
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
ORM::configure('driver_options', array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . Config::get(Config::MYSQL_CHARSET)));
}
}
static function NOW() {
@ -27,8 +30,13 @@ class Db
public static function get_dsn() {
$db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : '';
$db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : '';
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
$db_charset = ';charset=' . Config::get(Config::MYSQL_CHARSET);
} else {
$db_charset = '';
}
return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port;
return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset;
}
// this really shouldn't be used unless a separate PDO connection is needed

@ -0,0 +1,198 @@
<?php
class Db_Migrations {
private $base_filename = "schema.sql";
private $base_path;
private $migrations_path;
private $migrations_table;
private $base_is_latest;
private $pdo;
private $cached_version;
private $cached_max_version;
private $max_version_override;
function __construct() {
$this->pdo = Db::pdo();
}
function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql") {
$plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin);
$this->initialize($plugin_dir . "/${schema_suffix}",
strtolower("ttrss_migrations_plugin_" . get_class($plugin)),
$base_is_latest);
}
function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0) {
$this->base_path = "$root_path/" . Config::get(Config::DB_TYPE);
$this->migrations_path = $this->base_path . "/migrations";
$this->migrations_table = $migrations_table;
$this->base_is_latest = $base_is_latest;
$this->max_version_override = $max_version_override;
}
private function set_version(int $version) {
Debug::log("Updating table {$this->migrations_table} with version ${version}...", Debug::LOG_EXTENDED);
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
if ($res = $sth->fetch()) {
$sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?");
} else {
$sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)");
}
$sth->execute([$version]);
$this->cached_version = $version;
}
function get_version() : int {
if (isset($this->cached_version))
return $this->cached_version;
try {
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
if ($res = $sth->fetch()) {
return (int) $res['schema_version'];
} else {
return -1;
}
} catch (PDOException $e) {
$this->create_migrations_table();
return -1;
}
}
private function create_migrations_table() {
$this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)");
}
private function migrate_to(int $version) {
try {
if ($version <= $this->get_version()) {
Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE);
return false;
}
if ($version == 0)
Debug::log("Loading base database schema...", Debug::LOG_VERBOSE);
else
Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE);
$lines = $this->get_lines($version);
if (count($lines) > 0) {
// mysql doesn't support transactions for DDL statements
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->beginTransaction();
foreach ($lines as $line) {
Debug::log($line, Debug::LOG_EXTENDED);
try {
$this->pdo->query($line);
} catch (PDOException $e) {
Debug::log("Failed on line: $line", Debug::LOG_VERBOSE);
throw $e;
}
}
if ($version == 0 && $this->base_is_latest)
$this->set_version($this->get_max_version());
else
$this->set_version($version);
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->commit();
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}");
} else {
Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE);
}
} catch (PDOException $e) {
Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE);
try {
$this->pdo->rollback();
} catch (PDOException $ie) {
//
}
throw $e;
}
}
function get_max_version() : int {
if ($this->max_version_override > 0)
return $this->max_version_override;
if (isset($this->cached_max_version))
return $this->cached_max_version;
$migrations = glob("{$this->migrations_path}/*.sql");
if (count($migrations) > 0) {
natsort($migrations);
$this->cached_max_version = (int) basename(array_pop($migrations), ".sql");
} else {
$this->cached_max_version = 0;
}
return $this->cached_max_version;
}
function is_migration_needed() : bool {
return $this->get_version() != $this->get_max_version();
}
function migrate() : bool {
if ($this->get_version() == -1) {
try {
$this->migrate_to(0);
} catch (PDOException $e) {
user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false;
}
}
for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) {
try {
$this->migrate_to($i);
} catch (PDOException $e) {
user_error("Failed to apply migration ${i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false;
//throw $e;
}
}
return !$this->is_migration_needed();
}
private function get_lines(int $version) : array {
if ($version > 0)
$filename = "{$this->migrations_path}/${version}.sql";
else
$filename = "{$this->base_path}/{$this->base_filename}";
if (file_exists($filename)) {
$lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)),
function ($line) {
return strlen(trim($line)) > 0 && strpos($line, "--") !== 0;
});
return array_filter(explode(";", implode("", $lines)), function ($line) {
return strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]);
});
} else {
user_error("Requested schema file ${filename} not found.", E_USER_ERROR);
return [];
}
}
}

@ -1,82 +0,0 @@
<?php
class Db_Updater {
const SCHEMA_VERSION = 142;
private $pdo;
private $db_type;
function __construct($pdo, $db_type) {
$this->pdo = $pdo;
$this->db_type = $db_type;
}
/** always returns actual (=uncached) value */
private static function get_schema_version() {
return Config::get_schema_version(true);
}
static function is_update_required() {
return self::get_schema_version() < self::SCHEMA_VERSION;
}
function get_schema_lines($version) {
$filename = "schema/versions/".$this->db_type."/$version.sql";
if (file_exists($filename)) {
return explode(";", (string)preg_replace("/[\r\n]/", "", (string)file_get_contents($filename)));
} else {
user_error("DB Updater: schema file for version $version is not found.");
return false;
}
}
function update_to($version, $html_output = true) {
if ($this->get_schema_version() == $version - 1) {
$lines = $this->get_schema_lines($version);
if (is_array($lines)) {
$this->pdo->beginTransaction();
foreach ($lines as $line) {
if (strpos($line, "--") !== 0 && $line) {
if ($html_output)
print "<pre>$line</pre>";
else
Debug::log("> $line");
try {
$this->pdo->query($line); // PDO returns errors as exceptions now
} catch (PDOException $e) {
if ($html_output) {
print "<div class='text-error'>Error: " . $e->getMessage() . "</div>";
} else {
Debug::log("Error: " . $e->getMessage());
}
$this->pdo->rollBack();
return false;
}
}
}
$db_version = self::get_schema_version();
if ($db_version == $version) {
$this->pdo->commit();
return true;
} else {
$this->pdo->rollBack();
return false;
}
} else {
return false;
}
} else {
return false;
}
}
}

@ -1,14 +1,26 @@
<?php
class Debug {
public static $LOG_DISABLED = -1;
public static $LOG_NORMAL = 0;
public static $LOG_VERBOSE = 1;
public static $LOG_EXTENDED = 2;
const LOG_DISABLED = -1;
const LOG_NORMAL = 0;
const LOG_VERBOSE = 1;
const LOG_EXTENDED = 2;
/** @deprecated */
public static $LOG_DISABLED = self::LOG_DISABLED;
/** @deprecated */
public static $LOG_NORMAL = self::LOG_NORMAL;
/** @deprecated */
public static $LOG_VERBOSE = self::LOG_VERBOSE;
/** @deprecated */
public static $LOG_EXTENDED = self::LOG_EXTENDED;
private static $enabled = false;
private static $quiet = false;
private static $logfile = false;
private static $loglevel = 0;
private static $loglevel = self::LOG_NORMAL;
public static function set_logfile($logfile) {
self::$logfile = $logfile;
@ -34,7 +46,7 @@ class Debug {
return self::$loglevel;
}
public static function log($message, $level = 0) {
public static function log($message, int $level = 0) {
if (!self::$enabled || self::$loglevel < $level) return false;

@ -119,7 +119,7 @@ class Digest
link,
score,
content,
" . SUBSTRING_FOR_DATE . "(last_updated,1,19) AS last_updated
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM
ttrss_user_entries,ttrss_entries,ttrss_feeds
LEFT JOIN
@ -132,8 +132,8 @@ class Digest
AND unread = true
AND score >= 0
ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC
LIMIT :limit");
$sth->execute([':user_id' => $user_id, ':limit' => $limit]);
LIMIT " . (int)$limit);
$sth->execute([':user_id' => $user_id]);
$headlines_count = 0;
$headlines = array();

@ -9,7 +9,7 @@ abstract class FeedItem {
abstract function get_comments_url();
abstract function get_comments_count();
abstract function get_categories();
abstract function _get_enclosures();
abstract function get_enclosures();
abstract function get_author();
abstract function get_language();
}

@ -60,43 +60,76 @@ class FeedItem_Atom extends FeedItem_Common {
}
}
/** $base is optional (returns $content if $base is null), $content is an HTML string */
private function rewrite_content_to_base($base, $content) {
if (!empty($base) && !empty($content)) {
$tmpdoc = new DOMDocument();
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . $content)) {
$tmpxpath = new DOMXPath($tmpdoc);
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
foreach ($elems as $elem) {
if ($elem->hasAttribute("href")) {
$elem->setAttribute("href",
UrlHelper::rewrite_relative($base, $elem->getAttribute("href")));
} else if ($elem->hasAttribute("src")) {
$elem->setAttribute("src",
UrlHelper::rewrite_relative($base, $elem->getAttribute("src")));
}
}
return $tmpdoc->saveXML();
}
}
return $content;
}
function get_content() {
$content = $this->elem->getElementsByTagName("content")->item(0);
if ($content) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
if ($content->hasAttribute('type')) {
if ($content->getAttribute('type') == 'xhtml') {
for ($i = 0; $i < $content->childNodes->length; $i++) {
$child = $content->childNodes->item($i);
if ($child->hasChildNodes()) {
return $this->doc->saveHTML($child);
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
}
}
}
}
return $this->subtree_or_text($content);
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
}
}
// TODO: duplicate code should be merged with get_content()
function get_description() {
$content = $this->elem->getElementsByTagName("summary")->item(0);
if ($content) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
if ($content->hasAttribute('type')) {
if ($content->getAttribute('type') == 'xhtml') {
for ($i = 0; $i < $content->childNodes->length; $i++) {
$child = $content->childNodes->item($i);
if ($child->hasChildNodes()) {
return $this->doc->saveHTML($child);
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
}
}
}
}
return $this->subtree_or_text($content);
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
}
}
@ -119,26 +152,32 @@ class FeedItem_Atom extends FeedItem_Common {
return $this->normalize_categories($cats);
}
function _get_enclosures() {
function get_enclosures() {
$links = $this->elem->getElementsByTagName("link");
$encs = array();
$encs = [];
foreach ($links as $link) {
if ($link && $link->hasAttribute("href") && $link->hasAttribute("rel")) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
if ($link->getAttribute("rel") == "enclosure") {
$enc = new FeedEnclosure();
$enc->type = clean($link->getAttribute("type"));
$enc->link = clean($link->getAttribute("href"));
$enc->length = clean($link->getAttribute("length"));
$enc->link = clean($link->getAttribute("href"));
if (!empty($base)) {
$enc->link = UrlHelper::rewrite_relative($base, $enc->link);
}
array_push($encs, $enc);
}
}
}
$encs = array_merge($encs, parent::_get_enclosures());
$encs = array_merge($encs, parent::get_enclosures());
return $encs;
}

@ -78,7 +78,7 @@ abstract class FeedItem_Common extends FeedItem {
}
// this is common for both Atom and RSS types and deals with various media: elements
function _get_enclosures() {
function get_enclosures() {
$encs = [];
$enclosures = $this->xpath->query("media:content", $this->elem);
@ -190,6 +190,7 @@ abstract class FeedItem_Common extends FeedItem {
}, $tmp);
// remove empty values
// @phpstan-ignore-next-line
$tmp = array_filter($tmp, 'strlen');
asort($tmp);

@ -112,7 +112,7 @@ class FeedItem_RSS extends FeedItem_Common {
return $this->normalize_categories($cats);
}
function _get_enclosures() {
function get_enclosures() {
$enclosures = $this->elem->getElementsByTagName("enclosure");
$encs = array();
@ -129,7 +129,7 @@ class FeedItem_RSS extends FeedItem_Common {
array_push($encs, $enc);
}
$encs = array_merge($encs, parent::_get_enclosures());
$encs = array_merge($encs, parent::get_enclosures());
return $encs;
}

@ -251,21 +251,6 @@ class Feeds extends Handler_Protected {
$this->_mark_timestamp(" sanitize");
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM,
function ($result, $plugin) use (&$line) {
$line = $result;
$this->_mark_timestamp(" hook_render_cdm: " . get_class($plugin));
},
$line);
$this->_mark_timestamp(" hook_render_cdm");
$line['content'] = DiskCache::rewrite_urls($line['content']);
$this->_mark_timestamp(" disk_cache_rewrite");
$this->_mark_timestamp(" note");
if (!get_pref(Prefs::CDM_EXPANDED)) {
$line["cdm_excerpt"] = "<span class='collapse'>
<i class='material-icons' onclick='return Article.cdmUnsetActive(event)'
@ -330,6 +315,20 @@ class Feeds extends Handler_Protected {
}
$this->_mark_timestamp(" color");
$this->_mark_timestamp(" pre-hook_render_cdm");
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_CDM,
function ($result, $plugin) use (&$line) {
$line = $result;
$this->_mark_timestamp(" hook: " . get_class($plugin));
},
$line);
$this->_mark_timestamp(" hook_render_cdm");
$line['content'] = DiskCache::rewrite_urls($line['content']);
$this->_mark_timestamp(" disk_cache_rewrite");
/* we don't need those */
@ -588,6 +587,23 @@ class Feeds extends Handler_Protected {
]);
}
function opensite() {
$feed = ORM::for_table('ttrss_feeds')
->find_one((int)$_REQUEST['feed_id']);
if ($feed) {
$site_url = UrlHelper::validate($feed->site_url);
if ($site_url) {
header("Location: $site_url");
return;
}
}
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "Feed not found or has an empty site URL.";
}
function updatedebugger() {
header("Content-type: text/html");
@ -997,6 +1013,13 @@ class Feeds extends Handler_Protected {
if (!$url) return ["code" => 2];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_PRE_SUBSCRIBE,
/** @phpstan-ignore-next-line */
function ($result) use (&$url, &$auth_login, &$auth_pass) {
// arguments are updated inside the hook (if needed)
},
$url, $auth_login, $auth_pass);
$contents = UrlHelper::fetch($url, false, $auth_login, $auth_pass);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SUBSCRIBE_FEED,
@ -1047,7 +1070,7 @@ class Feeds extends Handler_Protected {
]);
if ($feed->save()) {
RSSUtils::set_basic_feed_info($feed->id);
RSSUtils::update_basic_info($feed->id);
return ["code" => 1, "feed_id" => (int) $feed->id];
}
@ -1093,19 +1116,44 @@ class Feeds extends Handler_Protected {
return false;
}
static function _find_by_url($feed_url, $owner_uid) {
$sth = Db::pdo()->prepare("SELECT id FROM ttrss_feeds WHERE
feed_url = ? AND owner_uid = ?");
$sth->execute([$feed_url, $owner_uid]);
static function _find_by_url(string $feed_url, int $owner_uid) {
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $owner_uid)
->where('feed_url', $feed_url)
->find_one();
if ($row = $sth->fetch()) {
return $row["id"];
if ($feed) {
return $feed->id;
} else {
return false;
}
}
return false;
/** $owner_uid defaults to $_SESSION['uid] */
static function _find_by_title(string $title, bool $cat = false, int $owner_uid = 0) {
$res = false;
if ($cat) {
$res = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid ? $owner_uid : $_SESSION['uid'])
->where('title', $title)
->find_one();
} else {
$res = ORM::for_table('ttrss_feeds')
->where('owner_uid', $owner_uid ? $owner_uid : $_SESSION['uid'])
->where('title', $title)
->find_one();
}
if ($res) {
return $res->id;
} else {
return false;
}
}
static function _get_title($id, $cat = false) {
static function _get_title($id, bool $cat = false) {
$pdo = Db::pdo();
if ($cat) {
@ -1152,7 +1200,7 @@ class Feeds extends Handler_Protected {
}
// only real cats
static function _get_cat_marked($cat, $owner_uid = false) {
static function _get_cat_marked(int $cat, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -1175,7 +1223,7 @@ class Feeds extends Handler_Protected {
}
}
static function _get_cat_unread($cat, $owner_uid = false) {
static function _get_cat_unread(int $cat, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -1209,7 +1257,7 @@ class Feeds extends Handler_Protected {
}
// only accepts real cats (>= 0)
static function _get_cat_children_unread($cat, $owner_uid = false) {
static function _get_cat_children_unread(int $cat, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
@ -1264,7 +1312,7 @@ class Feeds extends Handler_Protected {
}
}
private static function _get_label_unread($label_id, $owner_uid = false) {
private static function _get_label_unread($label_id, int $owner_uid = 0) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
@ -1751,9 +1799,10 @@ class Feeds extends Handler_Protected {
author, score,
(SELECT count(label_id) FROM ttrss_user_labels2 WHERE article_id = ttrss_entries.id) AS num_labels,
(SELECT count(id) FROM ttrss_enclosures WHERE post_id = ttrss_entries.id) AS num_enclosures
FROM ttrss_entries, ttrss_user_entries, ttrss_tags, ttrss_feeds
FROM ttrss_entries,
ttrss_user_entries LEFT JOIN ttrss_feeds ON (ttrss_feeds.id = ttrss_user_entries.feed_id),
ttrss_tags
WHERE
ttrss_feeds.id = ttrss_user_entries.feed_id AND
ref_id = ttrss_entries.id AND
ttrss_user_entries.owner_uid = ".$pdo->quote($owner_uid)." AND
post_int_id = int_id AND
@ -1777,7 +1826,7 @@ class Feeds extends Handler_Protected {
}
static function _get_parent_cats($cat, $owner_uid) {
static function _get_parent_cats(int $cat, int $owner_uid) {
$rv = array();
$pdo = Db::pdo();
@ -1794,7 +1843,7 @@ class Feeds extends Handler_Protected {
return $rv;
}
static function _get_child_cats($cat, $owner_uid) {
static function _get_child_cats(int $cat, int $owner_uid) {
$rv = array();
$pdo = Db::pdo();
@ -1840,7 +1889,7 @@ class Feeds extends Handler_Protected {
}
// returns Uncategorized as 0
static function _cat_of($feed) : int {
static function _cat_of(int $feed) : int {
$feed = ORM::for_table('ttrss_feeds')->find_one($feed);
if ($feed) {
@ -1970,7 +2019,7 @@ class Feeds extends Handler_Protected {
}
}
static function _purge($feed_id, $purge_interval) {
static function _purge(int $feed_id, int $purge_interval) {
if (!$purge_interval) $purge_interval = self::_get_purge_interval($feed_id);
@ -2040,24 +2089,21 @@ class Feeds extends Handler_Protected {
return $rows_deleted;
}
private static function _get_purge_interval($feed_id) {
private static function _get_purge_interval(int $feed_id) {
$feed = ORM::for_table('ttrss_feeds')->find_one($feed_id);
if ($feed) {
if ($feed->purge_interval != 0)
return $feed->purge_interval;
else
return get_pref(Prefs::PURGE_OLD_DAYS, $feed->owner_uid);
} else {
return -1;
}
}
private static function _search_to_sql($search, $search_language, $owner_uid) {
$keywords = str_getcsv(trim($search), " ");
$keywords = str_getcsv(preg_replace('/(-?\w+)\:"(\w+)/', '"${1}:${2}', trim($search)), ' ');
$query_keywords = array();
$search_words = array();
$search_query_leftover = array();

@ -53,6 +53,10 @@ class Handler_Public extends Handler {
if ($handler) {
$qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params);
} else {
user_error("Failed to find handler for plugin feed ID: $feed", E_USER_ERROR);
return false;
}
} else {
@ -125,7 +129,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : get_self_url_prefix()), true);
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ? $line['feed_title'] : $feed_title), true);
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
foreach ($line["tags"] as $tag) {
$tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true);
@ -152,7 +156,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true);
}
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url);
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line);
$tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true);
@ -266,19 +270,20 @@ class Handler_Public extends Handler {
$rv = [];
if ($login) {
$sth = $this->pdo->prepare("SELECT ttrss_settings_profiles.* FROM ttrss_settings_profiles,ttrss_users
WHERE ttrss_users.id = ttrss_settings_profiles.owner_uid AND LOWER(login) = LOWER(?) ORDER BY title");
$sth->execute([$login]);
$profiles = ORM::for_table('ttrss_settings_profiles')
->table_alias('p')
->select_many('title' , 'p.id')
->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->order_by_asc('title')
->find_many();
$rv = [ [ "value" => 0, "label" => __("Default profile") ] ];
while ($line = $sth->fetch()) {
$id = $line["id"];
$title = $line["title"];
array_push($rv, [ "label" => $title, "value" => $id ]);
foreach ($profiles as $profile) {
array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]);
}
}
}
print json_encode($rv);
}
@ -312,23 +317,20 @@ class Handler_Public extends Handler {
UserHelper::authenticate("admin", null);
}
$owner_id = false;
if ($key) {
$sth = $this->pdo->prepare("SELECT owner_uid FROM
ttrss_access_keys WHERE access_key = ? AND feed_id = ?");
$sth->execute([$key, $feed]);
if ($row = $sth->fetch())
$owner_id = $row["owner_uid"];
$access_key = ORM::for_table('ttrss_access_keys')
->select('owner_uid')
->where(['access_key' => $key, 'feed_id' => $feed])
->find_one();
if ($access_key) {
$this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit,
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
return;
}
}
if ($owner_id) {
$this->generate_syndicated_feed($owner_id, $feed, $is_cat, $limit,
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
} else {
header('HTTP/1.1 403 Forbidden');
}
header('HTTP/1.1 403 Forbidden');
}
function updateTask() {
@ -373,18 +375,13 @@ class Handler_Public extends Handler {
$_SESSION["safe_mode"] = $safe_mode;
if (!empty($_POST["profile"])) {
$profile = (int) clean($_POST["profile"]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles
WHERE id = ? AND owner_uid = ?");
$sth->execute([$profile, $_SESSION['uid']]);
$profile_obj = ORM::for_table('ttrss_settings_profiles')
->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']])
->find_one();
if ($sth->fetch()) {
$_SESSION["profile"] = $profile;
} else {
$_SESSION["profile"] = null;
}
$_SESSION["profile"] = $profile_obj ? $profile : null;
}
} else {
@ -415,7 +412,7 @@ class Handler_Public extends Handler {
startup_gettext();
session_start();
@$hash = clean($_REQUEST["hash"]);
$hash = clean($_REQUEST["hash"] ?? '');
header('Content-Type: text/html; charset=utf-8');
?>
@ -448,30 +445,27 @@ class Handler_Public extends Handler {
print "<h1>".__("Password recovery")."</h1>";
print "<div class='content'>";
@$method = clean($_POST['method']);
$method = clean($_POST['method'] ?? '');
if ($hash) {
$login = clean($_REQUEST["login"]);
if ($login) {
$sth = $this->pdo->prepare("SELECT id, resetpass_token FROM ttrss_users
WHERE LOWER(login) = LOWER(?)");
$sth->execute([$login]);
$user = ORM::for_table('ttrss_users')
->select_many('id', 'resetpass_token')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->find_one();
if ($row = $sth->fetch()) {
$id = $row["id"];
$resetpass_token_full = $row["resetpass_token"];
list($timestamp, $resetpass_token) = explode(":", $resetpass_token_full);
if ($user) {
list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token);
if ($timestamp && $resetpass_token &&
$timestamp >= time() - 15*60*60 &&
$resetpass_token === $hash) {
$user->resetpass_token = null;
$user->save();
$sth = $this->pdo->prepare("UPDATE ttrss_users SET resetpass_token = NULL
WHERE id = ?");
$sth->execute([$id]);
UserHelper::reset_password($id, true);
UserHelper::reset_password($user->id, true);
print "<p>"."Completed."."</p>";
@ -520,7 +514,6 @@ class Handler_Public extends Handler {
</form>";
} else if ($method == 'do') {
$login = clean($_POST["login"]);
$email = clean($_POST["email"]);
$test = clean($_POST["test"]);
@ -532,64 +525,51 @@ class Handler_Public extends Handler {
<input type='hidden' name='op' value='forgotpass'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>".__("Go back")."</button>
</form>";
} else {
// prevent submitting this form multiple times
$_SESSION["pwdreset:testvalue1"] = rand(1, 1000);
$_SESSION["pwdreset:testvalue2"] = rand(1, 1000);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users
WHERE LOWER(login) = LOWER(?) AND email = ?");
$sth->execute([$login, $email]);
$user = ORM::for_table('ttrss_users')
->select('id')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->where('email', $email)
->find_one();
if ($row = $sth->fetch()) {
if ($user) {
print_notice("Password reset instructions are being sent to your email address.");
$id = $row["id"];
if ($id) {
$resetpass_token = sha1(get_random_bytes(128));
$resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
"&login=" . urlencode($login);
$tpl = new Templator();
$resetpass_token = sha1(get_random_bytes(128));
$resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
"&login=" . urlencode($login);
$tpl->readTemplateFromFile("resetpass_link_template.txt");
$tpl = new Templator();
$tpl->setVariable('LOGIN', $login);
$tpl->setVariable('RESETPASS_LINK', $resetpass_link);
$tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$tpl->readTemplateFromFile("resetpass_link_template.txt");
$tpl->addBlock('message');
$tpl->setVariable('LOGIN', $login);
$tpl->setVariable('RESETPASS_LINK', $resetpass_link);
$tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$message = "";
$tpl->addBlock('message');
$tpl->generateOutputToString($message);
$message = "";
$mailer = new Mailer();
$tpl->generateOutputToString($message);
$rc = $mailer->mail(["to_name" => $login,
"to_address" => $email,
"subject" => __("[tt-rss] Password reset request"),
"message" => $message]);
$mailer = new Mailer();
if (!$rc) print_error($mailer->error());
$rc = $mailer->mail(["to_name" => $login,
"to_address" => $email,
"subject" => __("[tt-rss] Password reset request"),
"message" => $message]);
$resetpass_token_full = time() . ":" . $resetpass_token;
if (!$rc) print_error($mailer->error());
$sth = $this->pdo->prepare("UPDATE ttrss_users
SET resetpass_token = ?
WHERE LOWER(login) = LOWER(?) AND email = ?");
$sth->execute([$resetpass_token_full, $login, $email]);
} else {
print_error("User ID not found.");
}
$user->resetpass_token = time() . ":" . $resetpass_token;
$user->save();
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
} else {
print_error(__("Sorry, login and email combination not found."));
@ -597,17 +577,14 @@ class Handler_Public extends Handler {
<input type='hidden' name='op' value='forgotpass'>
<button dojoType='dijit.form.Button' type='submit'>".__("Go back")."</button>
</form>";
}
}
}
print "</div>";
print "</div>";
print "</body>";
print "</html>";
}
function dbupdate() {
@ -623,33 +600,55 @@ class Handler_Public extends Handler {
<!DOCTYPE html>
<html>
<head>
<title>Database Updater</title>
<title>Tiny Tiny RSS: Database Updater</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<?= stylesheet_tag("themes/light.css") ?>
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<?php
echo stylesheet_tag("themes/light.css");
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
?>
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"js/common.js",
"js/utility.js"] as $jsfile) {
echo javascript_tag($jsfile);
} ?>
<?= Config::get_override_links() ?>
<style type="text/css">
span.ok { color : #009000; font-weight : bold; }
span.err { color : #ff0000; font-weight : bold; }
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
<script type="text/javascript">
require({cache:{}});
</script>
</head>
<body class="flat ttrss_utility">
<body class="flat ttrss_utility css_loading">
<script type="text/javascript">
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
ready(function() {
parser.parse();
});
});
function confirmOP() {
return confirm("Update the database?");
const UtilityApp = {
init: function() {
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
ready(function() {
parser.parse();
});
});
}
}
function confirmDbUpdate() {
return confirm(__("Proceed with update?"));
}
</script>
@ -660,72 +659,66 @@ class Handler_Public extends Handler {
<?php
@$op = clean($_REQUEST["subop"] ?? "");
$updater = new Db_Updater(Db::pdo(), Config::get(Config::DB_TYPE));
if ($op == "performupdate") {
if (Db_Updater::is_update_required()) {
print "<h2>" . T_sprintf("Performing updates to version %d", Db_Updater::SCHEMA_VERSION) . "</h2>";
$migrations = Config::get_migrations();
for ($i = Config::get_schema_version(true) + 1; $i <= Db_Updater::SCHEMA_VERSION; $i++) {
print "<ul>";
print "<li class='text-info'>" . T_sprintf("Updating to version %d", $i) . "</li>";
if ($op == "performupdate") {
if ($migrations->is_migration_needed()) {
?>
print "<li>";
$result = $updater->update_to($i, true);
print "</li>";
<h2><?= T_sprintf("Performing updates to version %d", Config::SCHEMA_VERSION) ?></h2>
if (!$result) {
print "</ul>";
<code><pre class="small pre-wrap"><?php
Debug::set_enabled(true);
Debug::set_loglevel(Debug::LOG_VERBOSE);
$result = $migrations->migrate();
Debug::set_loglevel(Debug::LOG_NORMAL);
Debug::set_enabled(false);
?></pre></code>
print_error("One of the updates failed. Either retry the process or perform updates manually.");
<?php if (!$result) { ?>
<?= format_error("One of migrations failed. Either retry the process or perform updates manually.") ?>
print "<form method='POST'>
<input type='hidden' name='subop' value='performupdate'>
<button type='submit' dojoType='dijit.form.Button' class='alt-danger' onclick='return confirmOP()'>".__("Try again")."</button>
<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>
</form>";
<form method="post">
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
</form>
<?php } else { ?>
<?= format_notice("Update successful.") ?>
return;
} else {
print "<li class='text-success'>" . __("Completed.") . "</li>";
print "</ul>";
}
}
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
<?php }
print_notice("Your Tiny Tiny RSS database is now updated to the latest version.");
} else { ?>
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
<?= format_notice("Database is already up to date.") ?>
} else {
print_notice("Tiny Tiny RSS database is up to date.");
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
<?php
}
} else {
if (Db_Updater::is_update_required()) {
if ($migrations->is_migration_needed()) {
print "<h2>".T_sprintf("Tiny Tiny RSS database needs update to the latest version (%d to %d).",
Config::get_schema_version(true), Db_Updater::SCHEMA_VERSION)."</h2>";
?>
<h2><?= T_sprintf("Database schema needs update to the latest version (%d to %d).",
Config::get_schema_version(), Config::SCHEMA_VERSION) ?></h2>
if (Config::get(Config::DB_TYPE) == "mysql") {
print_error("<strong>READ THIS:</strong> Due to MySQL limitations, your database is not completely protected while updating. ".
"Errors may put it in an inconsistent state requiring manual rollback. <strong>BACKUP YOUR DATABASE BEFORE CONTINUING.</strong>");
} else {
print_warning("Please backup your database before proceeding.");
}
<?= format_warning("Please backup your database before proceeding.") ?>
print "<form method='POST'>
<input type='hidden' name='subop' value='performupdate'>
<button type='submit' dojoType='dijit.form.Button' class='alt-danger' onclick='return confirmOP()'>".__("Perform updates")."</button>
</form>";
<form method="post">
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
</form>
} else {
<?php
} else { ?>
<?= format_notice("Database is already up to date.") ?>
print_notice("Tiny Tiny RSS database is up to date.");
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
<?php
}
}
?>
@ -737,27 +730,6 @@ class Handler_Public extends Handler {
<?php
}
function publishOpml() {
$key = clean($_REQUEST["key"]);
$pdo = Db::pdo();
$sth = $pdo->prepare( "SELECT owner_uid
FROM ttrss_access_keys WHERE
access_key = ? AND feed_id = 'OPML:Publish'");
$sth->execute([$key]);
if ($row = $sth->fetch()) {
$owner_uid = $row['owner_uid'];
$opml = new OPML($_REQUEST);
$opml->opml_export("published.opml", $owner_uid, true, false);
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
}
}
function cached() {
list ($cache_dir, $filename) = explode("/", $_GET["file"], 2);
@ -816,9 +788,12 @@ class Handler_Public extends Handler {
}
}
static function _render_login_form() {
static function _render_login_form(string $return_to = "") {
header('Cache-Control: public');
if ($return_to)
$_REQUEST['return'] = $return_to;
require_once "login_form.php";
exit;
}

@ -3,6 +3,10 @@ class Logger {
private static $instance;
private $adapter;
const LOG_DEST_SQL = "sql";
const LOG_DEST_STDOUT = "stdout";
const LOG_DEST_SYSLOG = "syslog";
const ERROR_NAMES = [
1 => 'E_ERROR',
2 => 'E_WARNING',
@ -42,7 +46,7 @@ class Logger {
if ($this->adapter)
return $this->adapter->log_error($errno, $errstr, '', 0, $context);
else
return false;
return user_error($errstr, $errno);
}
private function __clone() {
@ -51,13 +55,13 @@ class Logger {
function __construct() {
switch (Config::get(Config::LOG_DESTINATION)) {
case "sql":
case self::LOG_DEST_SQL:
$this->adapter = new Logger_SQL();
break;
case "syslog":
case self::LOG_DEST_SYSLOG:
$this->adapter = new Logger_Syslog();
break;
case "stdout":
case self::LOG_DEST_STDOUT:
$this->adapter = new Logger_Stdout();
break;
default:

@ -4,7 +4,7 @@ class Mailer {
function mail($params) {
$to_name = $params["to_name"];
$to_name = $params["to_name"] ?? "";
$to_address = $params["to_address"];
$subject = $params["subject"];
$message = $params["message"];

@ -48,9 +48,7 @@ class OPML extends Handler_Protected {
// Export
private function opml_export_category($owner_uid, $cat_id, $hide_private_feeds = false, $include_settings = true) {
$cat_id = (int) $cat_id;
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true) {
if ($hide_private_feeds)
$hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')";
@ -126,7 +124,7 @@ class OPML extends Handler_Protected {
return $out;
}
function opml_export($filename, $owner_uid, $hide_private_feeds = false, $include_settings = true, $file_output = false) {
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false) {
if (!$owner_uid) return;
if (!$file_output)
@ -151,7 +149,7 @@ class OPML extends Handler_Protected {
# export tt-rss settings
if ($include_settings) {
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">";
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE
profile IS NULL AND owner_uid = ? ORDER BY pref_name");
@ -166,7 +164,7 @@ class OPML extends Handler_Protected {
$out .= "</outline>";
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">";
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
owner_uid = ?");
@ -183,13 +181,13 @@ class OPML extends Handler_Protected {
$out .= "</outline>";
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">";
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
WHERE owner_uid = ? ORDER BY id");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
$line["rules"] = array();
$line["actions"] = array();
@ -204,36 +202,36 @@ class OPML extends Handler_Protected {
$cat_filter = $tmp_line["cat_filter"];
if (!$tmp_line["match_on"]) {
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::_get_title(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$cat_filter);
} else {
$tmp_line["feed"] = "";
}
} else {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::_get_cat_title($feed_id), true, false]);
} else {
array_push($match, [0, true, true]);
}
} else {
if ($feed_id) {
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
} else {
array_push($match, [0, false, true]);
}
}
}
$tmp_line["match"] = $match;
unset($tmp_line["match_on"]);
}
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::_get_title(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$cat_filter);
} else {
$tmp_line["feed"] = "";
}
} else {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::_get_cat_title($feed_id), true, false]);
} else {
array_push($match, [0, true, true]);
}
} else {
if ($feed_id) {
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
} else {
array_push($match, [0, false, true]);
}
}
}
$tmp_line["match"] = $match;
unset($tmp_line["match_on"]);
}
unset($tmp_line["feed_id"]);
unset($tmp_line["cat_id"]);
@ -298,7 +296,7 @@ class OPML extends Handler_Protected {
// Import
private function opml_import_feed($node, $cat_id, $owner_uid) {
private function opml_import_feed(DOMNode $node, int $cat_id, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250);
@ -318,7 +316,7 @@ class OPML extends Handler_Protected {
if (!$sth->fetch()) {
#$this->opml_notice("[FEED] [$feed_title/$feed_url] dst_CAT=$cat_id");
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title));
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
if (!$cat_id) $cat_id = null;
@ -338,12 +336,12 @@ class OPML extends Handler_Protected {
$sth->execute([$feed_title, $feed_url, $owner_uid, $cat_id, $site_url, $order_id, $update_interval, $purge_interval]);
} else {
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title));
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
}
}
}
private function opml_import_label($node, $owner_uid) {
private function opml_import_label(DOMNode $node, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$label_name = $attrs->getNamedItem('label-name')->nodeValue;
@ -351,16 +349,16 @@ class OPML extends Handler_Protected {
$fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue;
$bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue;
if (!Labels::find_id($label_name, $_SESSION['uid'])) {
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)));
if (!Labels::find_id($label_name, $owner_uid)) {
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)), $nest);
Labels::create($label_name, $fg_color, $bg_color, $owner_uid);
} else {
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)));
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)), $nest);
}
}
}
private function opml_import_preference($node) {
private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
@ -368,13 +366,13 @@ class OPML extends Handler_Protected {
$pref_value = $attrs->getNamedItem('value')->nodeValue;
$this->opml_notice(T_sprintf("Setting preference key %s to %s",
$pref_name, $pref_value));
$pref_name, $pref_value), $nest);
set_pref($pref_name, $pref_value);
set_pref($pref_name, $pref_value, $owner_uid);
}
}
private function opml_import_filter($node) {
private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest) {
$attrs = $node->attributes;
$filter_type = $attrs->getNamedItem('filter-type')->nodeValue;
@ -393,47 +391,58 @@ class OPML extends Handler_Protected {
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid)
VALUES (?, ?, ?, ?, ?)");
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $_SESSION['uid']]);
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]);
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE
owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$sth->execute([$owner_uid]);
$row = $sth->fetch();
$filter_id = $row['id'];
if ($filter_id) {
$this->opml_notice(T_sprintf("Adding filter %s...", $title));
$this->opml_notice(T_sprintf("Adding filter %s...", $title), $nest);
//$this->opml_notice(json_encode($filter));
foreach ($filter["rules"] as $rule) {
$feed_id = null;
$cat_id = null;
if ($rule["match"]) {
if ($rule["match"] ?? false) {
$match_on = [];
$match_on = [];
foreach ($rule["match"] as $match) {
list ($name, $is_cat, $is_id) = $match;
foreach ($rule["match"] as $match) {
list ($name, $is_cat, $is_id) = $match;
if ($is_id) {
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
} else {
if ($is_id) {
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
} else {
$match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid);
if (!$is_cat) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
if ($match_id) {
if ($is_cat) {
array_push($match_on, "CAT:$match_id");
} else {
array_push($match_on, $match_id);
}
}
$tsth->execute([$name, $_SESSION['uid']]);
/*if (!$is_cat) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
if ($row = $tsth->fetch()) {
$match_id = $row['id'];
$tsth->execute([$name, $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$match_id = $row['id'];
array_push($match_on, $match_id);
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$name, $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
@ -441,54 +450,64 @@ class OPML extends Handler_Protected {
array_push($match_on, "CAT:$match_id");
}
}
}
}
} */
}
}
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$match_on = json_encode($match_on);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$match_on = json_encode($match_on);
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
(feed_id,cat_id,match_on,filter_id,filter_type,reg_exp,cat_filter,inverse)
VALUES
(NULL, NULL, ?, ?, ?, ?, false, ?)");
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
VALUES
(NULL, NULL, ?, ?, ?, ?, false, ?)");
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
} else {
} else {
if (!$rule["cat_filter"]) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid);
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($match_id) {
if ($rule['cat_filter']) {
$cat_id = $match_id;
} else {
$feed_id = $match_id;
}
}
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
} else {
/*if (!$rule["cat_filter"]) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
}
} */
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
(feed_id,cat_id,filter_id,filter_type,reg_exp,cat_filter,inverse)
VALUES
(?, ?, ?, ?, ?, ?, ?)");
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
}
VALUES
(?, ?, ?, ?, ?, ?, ?)");
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
}
}
foreach ($filter["actions"] as $action) {
@ -507,8 +526,8 @@ class OPML extends Handler_Protected {
}
}
private function opml_import_category($doc, $root_node, $owner_uid, $parent_id) {
$default_cat_id = (int) $this->get_feed_category('Imported feeds', false);
private function opml_import_category(DOMDocument $doc, ?DOMNode $root_node, int $owner_uid, int $parent_id, int $nest) {
$default_cat_id = (int) $this->get_feed_category('Imported feeds', $owner_uid, 0);
if ($root_node) {
$cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250);
@ -517,13 +536,13 @@ class OPML extends Handler_Protected {
$cat_title = mb_substr($root_node->attributes->getNamedItem('title')->nodeValue, 0, 250);
if (!in_array($cat_title, array("tt-rss-filters", "tt-rss-labels", "tt-rss-prefs"))) {
$cat_id = $this->get_feed_category($cat_title, $parent_id);
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
if ($cat_id === false) {
if ($cat_id === 0) {
$order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue;
Feeds::_add_cat($cat_title, $_SESSION['uid'], $parent_id ? $parent_id : null, (int)$order_id);
$cat_id = $this->get_feed_category($cat_title, $parent_id);
Feeds::_add_cat($cat_title, $owner_uid, $parent_id ? $parent_id : null, (int)$order_id);
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
}
} else {
@ -540,21 +559,21 @@ class OPML extends Handler_Protected {
$cat_title = false;
}
#$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")));
//$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")), $nest);
foreach ($outlines as $node) {
if ($node->hasAttributes() && strtolower($node->tagName) == "outline") {
$attrs = $node->attributes;
$node_cat_title = $attrs->getNamedItem('text')->nodeValue;
$node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false;
if (!$node_cat_title)
$node_cat_title = $attrs->getNamedItem('title')->nodeValue;
$node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false;
$node_feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
$node_feed_url = $attrs->getNamedItem('xmlUrl') ? $attrs->getNamedItem('xmlUrl')->nodeValue : false;
if ($node_cat_title && !$node_feed_url) {
$this->opml_import_category($doc, $node, $owner_uid, $cat_id);
$this->opml_import_category($doc, $node, $owner_uid, $cat_id, $nest+1);
} else {
if (!$cat_id) {
@ -565,97 +584,112 @@ class OPML extends Handler_Protected {
switch ($cat_title) {
case "tt-rss-prefs":
$this->opml_import_preference($node);
$this->opml_import_preference($node, $owner_uid, $nest+1);
break;
case "tt-rss-labels":
$this->opml_import_label($node, $owner_uid);
$this->opml_import_label($node, $owner_uid, $nest+1);
break;
case "tt-rss-filters":
$this->opml_import_filter($node);
$this->opml_import_filter($node, $owner_uid, $nest+1);
break;
default:
$this->opml_import_feed($node, $dst_cat_id, $owner_uid);
$this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1);
}
}
}
}
}
function opml_import($owner_uid) {
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
function opml_import(int $owner_uid, string $filename = "") {
if (!$owner_uid) return;
$doc = false;
if ($_FILES['opml_file']['error'] != 0) {
print_error(T_sprintf("Upload failed with error code %d",
$_FILES['opml_file']['error']));
return;
}
if (!$filename) {
if ($_FILES['opml_file']['error'] != 0) {
print_error(T_sprintf("Upload failed with error code %d",
$_FILES['opml_file']['error']));
return false;
}
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
$tmp_file);
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
$tmp_file);
if (!$result) {
print_error(__("Unable to move uploaded file."));
return;
if (!$result) {
print_error(__("Unable to move uploaded file."));
return false;
}
} else {
print_error(__('Error: please upload OPML file.'));
return false;
}
} else {
print_error(__('Error: please upload OPML file.'));
return;
$tmp_file = $filename;
}
if (!is_readable($tmp_file)) {
$this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename));
return false;
}
$loaded = false;
if (is_file($tmp_file)) {
$doc = new DOMDocument();
$doc = new DOMDocument();
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(false);
$loaded = $doc->load($tmp_file);
}
$loaded = $doc->load($tmp_file);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
unlink($tmp_file);
} else if (empty($doc)) {
print_error(__('Error: unable to find moved OPML file.'));
return;
}
// only remove temporary i.e. HTTP uploaded files
if (!$filename)
unlink($tmp_file);
if ($loaded) {
$this->pdo->beginTransaction();
$this->opml_import_category($doc, false, $owner_uid, false);
$this->pdo->commit();
// we're using ORM while importing so we can't transaction-lock things anymore
//$this->pdo->beginTransaction();
$this->opml_import_category($doc, null, $owner_uid, 0, 0);
//$this->pdo->commit();
} else {
print_error(__('Error while parsing document.'));
$this->opml_notice(__('Error while parsing document.'));
return false;
}
}
private function opml_notice($msg) {
print "$msg<br/>";
return true;
}
static function get_publish_url(){
return Config::get_self_url() .
"/public.php?op=publishOpml&key=" .
Feeds::_get_access_key('OPML:Publish', false, $_SESSION["uid"]);
private function opml_notice(string $msg, int $prefix_length = 0) {
if (php_sapi_name() == "cli") {
Debug::log(str_repeat(" ", $prefix_length) . $msg);
} else {
// TODO: use better separator i.e. CSS-defined span of certain width or something
print str_repeat("&nbsp;&nbsp;&nbsp;", $prefix_length) . $msg . "<br/>";
}
}
function get_feed_category($feed_cat, $parent_cat_id = false) {
$parent_cat_id = (int) $parent_cat_id;
function get_feed_category(string $feed_cat, int $owner_uid, int $parent_cat_id) : int {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = :title
AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL))
AND owner_uid = :uid");
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $_SESSION['uid']]);
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $owner_uid]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return false;
return 0;
}
}
}

@ -23,56 +23,153 @@ class PluginHost {
// Hooks marked with *1 are run in global context and available
// to plugins loaded in config.php only
const HOOK_ARTICLE_BUTTON = "hook_article_button"; // hook_article_button($line)
const HOOK_ARTICLE_FILTER = "hook_article_filter"; // hook_article_filter($article)
const HOOK_PREFS_TAB = "hook_prefs_tab"; // hook_prefs_tab($tab)
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section"; // hook_prefs_tab_section($section)
const HOOK_PREFS_TABS = "hook_prefs_tabs"; // hook_prefs_tabs()
const HOOK_FEED_PARSED = "hook_feed_parsed"; // hook_feed_parsed($parser, $feed_id)
const HOOK_UPDATE_TASK = "hook_update_task"; //*1 // GLOBAL: hook_update_task($cli_options)
const HOOK_AUTH_USER = "hook_auth_user"; // hook_auth_user($login, $password, $service) (byref)
const HOOK_HOTKEY_MAP = "hook_hotkey_map"; // hook_hotkey_map($hotkeys) (byref)
const HOOK_RENDER_ARTICLE = "hook_render_article"; // hook_render_article($article)
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm"; // hook_render_article_cdm($article)
const HOOK_FEED_FETCHED = "hook_feed_fetched"; // hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) (byref)
const HOOK_SANITIZE = "hook_sanitize"; // hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) (byref)
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api"; // hook_render_article_api($params)
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button"; // hook_toolbar_button()
const HOOK_ACTION_ITEM = "hook_action_item"; // hook_action_item()
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button"; // hook_headline_toolbar_button($feed_id, $is_cat)
const HOOK_HOTKEY_INFO = "hook_hotkey_info"; // hook_hotkey_info($hotkeys) (byref)
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button"; // hook_article_left_button($row)
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed"; // hook_prefs_edit_feed($feed_id)
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed"; // hook_prefs_save_feed($feed_id)
const HOOK_FETCH_FEED = "hook_fetch_feed"; // hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) (byref)
const HOOK_QUERY_HEADLINES = "hook_query_headlines"; // hook_query_headlines($row) (byref)
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1 // GLOBAL: hook_house_keeping()
const HOOK_SEARCH = "hook_search"; // hook_search($query)
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures"; // hook__format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images) (byref)
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed"; // hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) (byref)
const HOOK_HEADLINES_BEFORE = "hook_headlines_before"; // hook_headlines_before($feed, $is_cat, $qfh_ret)
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure"; // hook_render_enclosure($entry, $id, $rv)
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action"; // hook_article_filter_action($article, $action)
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed"; // hook_article_export_feed($line, $feed, $is_cat, $owner_uid) (byref)
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button"; // hook_main_toolbar_button()
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry"; // hook_enclosure_entry($entry, $id, $rv) (byref)
const HOOK_FORMAT_ARTICLE = "hook_format_article"; // hook_format_article($html, $row)
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm"; /* RIP */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info"; // hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) (byref)
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file"; // hook_send_local_file($filename)
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed"; // hook_unsubscribe_feed($feed_id, $owner_uid)
const HOOK_SEND_MAIL = "hook_send_mail"; // hook_send_mail(Mailer $mailer, $params)
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered"; // hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters)
const HOOK_GET_FULL_TEXT = "hook_get_full_text"; // hook_get_full_text($url)
const HOOK_ARTICLE_IMAGE = "hook_article_image"; // hook_article_image($enclosures, $content, $site_url)
const HOOK_FEED_TREE = "hook_feed_tree"; // hook_feed_tree()
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted"; // hook_iframe_whitelisted($url)
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported"; // hook_enclosure_imported($enclosure, $feed)
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map"; // hook_headlines_custom_sort_map()
/** hook_article_button($line) */
const HOOK_ARTICLE_BUTTON = "hook_article_button";
/** hook_article_filter($article) */
const HOOK_ARTICLE_FILTER = "hook_article_filter";
/** hook_prefs_tab($tab) */
const HOOK_PREFS_TAB = "hook_prefs_tab";
/** hook_prefs_tab_section($section) */
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section";
/** hook_prefs_tabs() */
const HOOK_PREFS_TABS = "hook_prefs_tabs";
/** hook_feed_parsed($parser, $feed_id) */
const HOOK_FEED_PARSED = "hook_feed_parsed";
/** GLOBAL: hook_update_task($cli_options) */
const HOOK_UPDATE_TASK = "hook_update_task"; //*1
/** hook_auth_user($login, $password, $service) (byref) */
const HOOK_AUTH_USER = "hook_auth_user";
/** hook_hotkey_map($hotkeys) (byref) */
const HOOK_HOTKEY_MAP = "hook_hotkey_map";
/** hook_render_article($article) */
const HOOK_RENDER_ARTICLE = "hook_render_article";
/** hook_render_article_cdm($article) */
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm";
/** hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) (byref) */
const HOOK_FEED_FETCHED = "hook_feed_fetched";
/** hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) (byref) */
const HOOK_SANITIZE = "hook_sanitize";
/** hook_render_article_api($params) */
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api";
/** hook_toolbar_button() */
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button";
/** hook_action_item() */
const HOOK_ACTION_ITEM = "hook_action_item";
/** hook_headline_toolbar_button($feed_id, $is_cat) */
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button";
/** hook_hotkey_info($hotkeys) (byref) */
const HOOK_HOTKEY_INFO = "hook_hotkey_info";
/** hook_article_left_button($row) */
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button";
/** hook_prefs_edit_feed($feed_id) */
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed";
/** hook_prefs_save_feed($feed_id) */
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed";
/** hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) (byref) */
const HOOK_FETCH_FEED = "hook_fetch_feed";
/** hook_query_headlines($row) (byref) */
const HOOK_QUERY_HEADLINES = "hook_query_headlines";
/** GLOBAL: hook_house_keeping() */
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1
/** hook_search($query) */
const HOOK_SEARCH = "hook_search";
/** hook_format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images) (byref) */
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures";
/** hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) (byref) */
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed";
/** hook_headlines_before($feed, $is_cat, $qfh_ret) */
const HOOK_HEADLINES_BEFORE = "hook_headlines_before";
/** hook_render_enclosure($entry, $id, $rv) */
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure";
/** hook_article_filter_action($article, $action) */
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action";
/** hook_article_export_feed($line, $feed, $is_cat, $owner_uid) (byref) */
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed";
/** hook_main_toolbar_button() */
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button";
/** hook_enclosure_entry($entry, $id, $rv) (byref) */
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry";
/** hook_format_article($html, $row) */
const HOOK_FORMAT_ARTICLE = "hook_format_article";
/** @deprecated removed, do not use */
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm";
/** hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) (byref) */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
/** hook_send_local_file($filename) */
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file";
/** hook_unsubscribe_feed($feed_id, $owner_uid) */
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed";
/** hook_send_mail(Mailer $mailer, $params) */
const HOOK_SEND_MAIL = "hook_send_mail";
/** hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) */
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered";
/** hook_get_full_text($url) */
const HOOK_GET_FULL_TEXT = "hook_get_full_text";
/** hook_article_image($enclosures, $content, $site_url) */
const HOOK_ARTICLE_IMAGE = "hook_article_image";
/** hook_feed_tree() */
const HOOK_FEED_TREE = "hook_feed_tree";
/** hook_iframe_whitelisted($url) */
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted";
/** hook_enclosure_imported($enclosure, $feed) */
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported";
/** hook_headlines_custom_sort_map() */
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map";
/** hook_headlines_custom_sort_override($order) */
const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = "hook_headlines_custom_sort_override";
// hook_headlines_custom_sort_override($order)
/** hook_headline_toolbar_select_menu_item($feed_id, $is_cat) */
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item";
// hook_headline_toolbar_select_menu_item($feed_id, $is_cat)
/** hook_pre_subscribe($url, $auth_login, $auth_pass) (byref) */
const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe";
const KIND_ALL = 1;
const KIND_SYSTEM = 2;
@ -103,12 +200,12 @@ class PluginHost {
$this->plugins[$name] = $plugin;
}
// needed for compatibility with API 1
/** needed for compatibility with API 1 */
function get_link() {
return false;
}
// needed for compatibility with API 2 (?)
/** needed for compatibility with API 2 (?) */
function get_dbh() {
return false;
}
@ -352,7 +449,7 @@ class PluginHost {
$method = strtolower($method);
if ($this->is_system($sender)) {
if (!is_array($this->handlers[$handler])) {
if (!isset($this->handlers[$handler])) {
$this->handlers[$handler] = array();
}
@ -469,7 +566,30 @@ class PluginHost {
}
}
function set(Plugin $sender, string $name, $value, bool $sync = true) {
// same as set(), but sets data to current preference profile
function profile_set(Plugin $sender, string $name, $value) {
$profile_id = $_SESSION["profile"] ?? null;
if ($profile_id) {
$idx = get_class($sender);
if (!isset($this->storage[$idx])) {
$this->storage[$idx] = [];
}
if (!isset($this->storage[$idx][$profile_id])) {
$this->storage[$idx][$profile_id] = [];
}
$this->storage[$idx][$profile_id][$name] = $value;
$this->save_data(get_class($sender));
} else {
return $this->set($sender, $name, $value);
}
}
function set(Plugin $sender, string $name, $value) {
$idx = get_class($sender);
if (!isset($this->storage[$idx]))
@ -477,7 +597,39 @@ class PluginHost {
$this->storage[$idx][$name] = $value;
if ($sync) $this->save_data(get_class($sender));
$this->save_data(get_class($sender));
}
function set_array(Plugin $sender, array $params) {
$idx = get_class($sender);
if (!isset($this->storage[$idx]))
$this->storage[$idx] = array();
foreach ($params as $name => $value)
$this->storage[$idx][$name] = $value;
$this->save_data(get_class($sender));
}
// same as get(), but sets data to current preference profile
function profile_get(Plugin $sender, string $name, $default_value = false) {
$profile_id = $_SESSION["profile"] ?? null;
if ($profile_id) {
$idx = get_class($sender);
$this->load_data();
if (isset($this->storage[$idx][$profile_id][$name])) {
return $this->storage[$idx][$profile_id][$name];
} else {
return $default_value;
}
} else {
return $this->get($sender, $name, $default_value);
}
}
function get(Plugin $sender, string $name, $default_value = false) {
@ -623,4 +775,15 @@ class PluginHost {
user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private.");
}
}
function get_plugin_dir(Plugin $plugin) {
$ref = new ReflectionClass(get_class($plugin));
return dirname($ref->getFileName());
}
// TODO: use get_plugin_dir()
function is_local(Plugin $plugin) {
$ref = new ReflectionClass(get_class($plugin));
return basename(dirname(dirname($ref->getFileName()))) == "plugins.local";
}
}

@ -1,5 +1,10 @@
<?php
class Pref_Feeds extends Handler_Protected {
const E_ICON_FILE_TOO_LARGE = 'E_ICON_FILE_TOO_LARGE';
const E_ICON_RENAME_FAILED = 'E_ICON_RENAME_FAILED';
const E_ICON_UPLOAD_FAILED = 'E_ICON_UPLOAD_FAILED';
const E_ICON_UPLOAD_SUCCESS = 'E_ICON_UPLOAD_SUCCESS';
function csrf_ignore($method) {
$csrf_ignored = array("index", "getfeedtree", "savefeedorder");
@ -7,19 +12,12 @@ class Pref_Feeds extends Handler_Protected {
}
public static function get_ts_languages() {
$rv = [];
if (Config::get(Config::DB_TYPE) == "pgsql") {
$dbh = Db::pdo();
$res = $dbh->query("SELECT cfgname FROM pg_ts_config");
while ($row = $res->fetch()) {
array_push($rv, ucfirst($row['cfgname']));
}
if (Config::get(Config::DB_TYPE) == 'pgsql') {
return array_map('ucfirst',
array_column(ORM::for_table('pg_ts_config')->select('cfgname')->find_array(), 'cfgname'));
}
return $rv;
return [];
}
function renameCat() {
@ -46,61 +44,60 @@ class Pref_Feeds extends Handler_Protected {
$show_empty_cats = clean($_REQUEST['force_show_empty'] ?? false) ||
(clean($_REQUEST['mode'] ?? 0) != 2 && !$search);
$items = array();
$sth = $this->pdo->prepare("SELECT id, title FROM ttrss_feed_categories
WHERE owner_uid = ? AND parent_cat = ? ORDER BY order_id, title");
$sth->execute([$_SESSION['uid'], $cat_id]);
while ($line = $sth->fetch()) {
$cat = array();
$cat['id'] = 'CAT:' . $line['id'];
$cat['bare_id'] = (int)$line['id'];
$cat['name'] = $line['title'];
$cat['items'] = array();
$cat['checkbox'] = false;
$cat['type'] = 'category';
$cat['unread'] = -1;
$cat['child_unread'] = -1;
$cat['auxcounter'] = -1;
$cat['parent_id'] = $cat_id;
$cat['items'] = $this->get_category_items($line['id']);
$items = [];
$feed_categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title')
->where(['owner_uid' => $_SESSION['uid'], 'parent_cat' => $cat_id])
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
foreach ($feed_categories as $feed_category) {
$cat = [
'id' => 'CAT:' . $feed_category->id,
'bare_id' => (int)$feed_category->id,
'name' => $feed_category->title,
'items' => $this->get_category_items($feed_category->id),
'checkbox' => false,
'type' => 'category',
'unread' => -1,
'child_unread' => -1,
'auxcounter' => -1,
'parent_id' => $cat_id,
];
$num_children = $this->calculate_children_count($cat);
$cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children);
if ($num_children > 0 || $show_empty_cats)
array_push($items, $cat);
}
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where(['cat_id' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
}
$fsth = $this->pdo->prepare("SELECT id, title, last_error,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval
FROM ttrss_feeds
WHERE cat_id = :cat AND
owner_uid = :uid AND
(:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search))
ORDER BY order_id, title");
$fsth->execute([":cat" => $cat_id, ":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]);
while ($feed_line = $fsth->fetch()) {
$feed = array();
$feed['id'] = 'FEED:' . $feed_line['id'];
$feed['bare_id'] = (int)$feed_line['id'];
$feed['auxcounter'] = -1;
$feed['name'] = $feed_line['title'];
$feed['checkbox'] = false;
$feed['unread'] = -1;
$feed['error'] = $feed_line['last_error'];
$feed['icon'] = Feeds::_get_icon($feed_line['id']);
$feed['param'] = TimeHelper::make_local_datetime(
$feed_line['last_updated'], true);
$feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0);
array_push($items, $feed);
foreach ($feeds_obj->find_many() as $feed) {
array_push($items, [
'id' => 'FEED:' . $feed->id,
'bare_id' => (int) $feed->id,
'auxcounter' => -1,
'name' => $feed->title,
'checkbox' => false,
'unread' => -1,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
return $items;
@ -176,24 +173,23 @@ class Pref_Feeds extends Handler_Protected {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
$cat = $this->feedlist_init_cat(-2);
} else {
$cat['items'] = array();
$cat['items'] = [];
}
$num_labels = 0;
while ($line = $sth->fetch()) {
++$num_labels;
$label_id = Labels::label_to_feed_id($line['id']);
$feed = $this->feedlist_init_feed($label_id, false, 0);
$feed['fg_color'] = $line['fg_color'];
$feed['bg_color'] = $line['bg_color'];
array_push($cat['items'], $feed);
}
$labels = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('caption')
->find_many();
if (count($labels)) {
foreach ($labels as $label) {
$label_id = Labels::label_to_feed_id($label->id);
$feed = $this->feedlist_init_feed($label_id, false, 0);
$feed['fg_color'] = $label->fg_color;
$feed['bg_color'] = $label->bg_color;
array_push($cat['items'], $feed);
}
if ($num_labels) {
if ($enable_cats) {
array_push($root['items'], $cat);
} else {
@ -206,23 +202,26 @@ class Pref_Feeds extends Handler_Protected {
$show_empty_cats = clean($_REQUEST['force_show_empty'] ?? false) ||
(clean($_REQUEST['mode'] ?? 0) != 2 && !$search);
$sth = $this->pdo->prepare("SELECT id, title FROM ttrss_feed_categories
WHERE owner_uid = ? AND parent_cat IS NULL ORDER BY order_id, title");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$cat = array();
$cat['id'] = 'CAT:' . $line['id'];
$cat['bare_id'] = (int)$line['id'];
$cat['auxcounter'] = -1;
$cat['name'] = $line['title'];
$cat['items'] = array();
$cat['checkbox'] = false;
$cat['type'] = 'category';
$cat['unread'] = -1;
$cat['child_unread'] = -1;
$cat['items'] = $this->get_category_items($line['id']);
$feed_categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title')
->where('owner_uid', $_SESSION['uid'])
->where_null('parent_cat')
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
foreach ($feed_categories as $feed_category) {
$cat = [
'id' => 'CAT:' . $feed_category->id,
'bare_id' => (int) $feed_category->id,
'auxcounter' => -1,
'name' => $feed_category->title,
'items' => $this->get_category_items($feed_category->id),
'checkbox' => false,
'type' => 'category',
'unread' => -1,
'child_unread' => -1,
];
$num_children = $this->calculate_children_count($cat);
$cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children);
@ -230,47 +229,48 @@ class Pref_Feeds extends Handler_Protected {
if ($num_children > 0 || $show_empty_cats)
array_push($root['items'], $cat);
$root['param'] += count($cat['items']);
//$root['param'] += count($cat['items']);
}
/* Uncategorized is a special case */
$cat = [
'id' => 'CAT:0',
'bare_id' => 0,
'auxcounter' => -1,
'name' => __('Uncategorized'),
'items' => [],
'type' => 'category',
'checkbox' => false,
'unread' => -1,
'child_unread' => -1,
];
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->where_null('cat_id')
->order_by_asc('order_id')
->order_by_asc('title');
$cat = array();
$cat['id'] = 'CAT:0';
$cat['bare_id'] = 0;
$cat['auxcounter'] = -1;
$cat['name'] = __("Uncategorized");
$cat['items'] = array();
$cat['type'] = 'category';
$cat['checkbox'] = false;
$cat['unread'] = -1;
$cat['child_unread'] = -1;
$fsth = $this->pdo->prepare("SELECT id, title,last_error,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval
FROM ttrss_feeds
WHERE cat_id IS NULL AND
owner_uid = :uid AND
(:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search))
ORDER BY order_id, title");
$fsth->execute([":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]);
while ($feed_line = $fsth->fetch()) {
$feed = array();
$feed['id'] = 'FEED:' . $feed_line['id'];
$feed['bare_id'] = (int)$feed_line['id'];
$feed['auxcounter'] = -1;
$feed['name'] = $feed_line['title'];
$feed['checkbox'] = false;
$feed['error'] = $feed_line['last_error'];
$feed['icon'] = Feeds::_get_icon($feed_line['id']);
$feed['param'] = TimeHelper::make_local_datetime(
$feed_line['last_updated'], true);
$feed['unread'] = -1;
$feed['type'] = 'feed';
$feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0);
array_push($cat['items'], $feed);
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
}
foreach ($feeds_obj->find_many() as $feed) {
array_push($cat['items'], [
'id' => 'FEED:' . $feed->id,
'bare_id' => (int) $feed->id,
'auxcounter' => -1,
'name' => $feed->title,
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
$cat['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($cat['items'])), count($cat['items']));
@ -282,46 +282,41 @@ class Pref_Feeds extends Handler_Protected {
$root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', (int) $num_children), $num_children);
} else {
$fsth = $this->pdo->prepare("SELECT id, title, last_error,
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated, update_interval
FROM ttrss_feeds
WHERE owner_uid = :uid AND
(:search = '' OR (LOWER(title) LIKE :search OR LOWER(feed_url) LIKE :search))
ORDER BY order_id, title");
$fsth->execute([":uid" => $_SESSION['uid'], ":search" => $search ? "%$search%" : ""]);
while ($feed_line = $fsth->fetch()) {
$feed = array();
$feed['id'] = 'FEED:' . $feed_line['id'];
$feed['bare_id'] = (int)$feed_line['id'];
$feed['auxcounter'] = -1;
$feed['name'] = $feed_line['title'];
$feed['checkbox'] = false;
$feed['error'] = $feed_line['last_error'];
$feed['icon'] = Feeds::_get_icon($feed_line['id']);
$feed['param'] = TimeHelper::make_local_datetime(
$feed_line['last_updated'], true);
$feed['unread'] = -1;
$feed['type'] = 'feed';
$feed['updates_disabled'] = (int)($feed_line['update_interval'] < 0);
array_push($root['items'], $feed);
}
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('order_id')
->order_by_asc('title');
$root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($root['items'])), count($root['items']));
}
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
}
$fl = array();
$fl['identifier'] = 'id';
$fl['label'] = 'name';
foreach ($feeds_obj->find_many() as $feed) {
array_push($root['items'], [
'id' => 'FEED:' . $feed->id,
'bare_id' => (int) $feed->id,
'auxcounter' => -1,
'name' => $feed->title,
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
if (clean($_REQUEST['mode'] ?? 0) != 2) {
$fl['items'] = array($root);
} else {
$fl['items'] = $root['items'];
$root['param'] = sprintf(_ngettext('(%d feed)', '(%d feeds)', count($root['items'])), count($root['items']));
}
return $fl;
return [
'identifier' => 'id',
'label' => 'name',
'items' => clean($_REQUEST['mode'] ?? 0) != 2 ? [$root] : $root['items'],
];
}
function catsortreset() {
@ -354,10 +349,14 @@ class Pref_Feeds extends Handler_Protected {
$parent_qpart = null;
}
$sth = $this->pdo->prepare("UPDATE ttrss_feed_categories
SET parent_cat = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$parent_qpart, $bare_item_id, $_SESSION['uid']]);
$feed_category = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $_SESSION['uid'])
->find_one($bare_item_id);
if ($feed_category) {
$feed_category->parent_cat = $parent_qpart;
$feed_category->save();
}
}
$order_id = 1;
@ -375,22 +374,27 @@ class Pref_Feeds extends Handler_Protected {
if (strpos($id, "FEED") === 0) {
$cat_id = ($item_id != "root") ? $bare_item_id : null;
$sth = $this->pdo->prepare("UPDATE ttrss_feeds
SET order_id = ?, cat_id = ?
WHERE id = ? AND owner_uid = ?");
$sth->execute([$order_id, $cat_id ? $cat_id : null, $bare_id, $_SESSION['uid']]);
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($bare_id);
if ($feed) {
$feed->order_id = $order_id;
$feed->cat_id = ($item_id != "root" && $bare_item_id) ? $bare_item_id : null;
$feed->save();
}
} else if (strpos($id, "CAT:") === 0) {
$this->process_category_order($data_map, $item['_reference'], $item_id,
$nest_level+1);
$sth = $this->pdo->prepare("UPDATE ttrss_feed_categories
SET order_id = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$order_id, $bare_id, $_SESSION['uid']]);
$feed_category = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $_SESSION['uid'])
->find_one($bare_id);
if ($feed_category) {
$feed_category->order_id = $order_id;
$feed_category->save();
}
}
}
@ -435,78 +439,67 @@ class Pref_Feeds extends Handler_Protected {
}
}
function removeicon() {
$feed_id = clean($_REQUEST["feed_id"]);
function removeIcon() {
$feed_id = (int) $_REQUEST["feed_id"];
$icon_file = Config::get(Config::ICONS_DIR) . "/$feed_id.ico";
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($feed_id);
if ($row = $sth->fetch()) {
@unlink(Config::get(Config::ICONS_DIR) . "/$feed_id.ico");
$sth = $this->pdo->prepare("UPDATE ttrss_feeds SET favicon_avg_color = NULL, favicon_last_checked = '1970-01-01'
where id = ?");
$sth->execute([$feed_id]);
if ($feed && file_exists($icon_file)) {
if (unlink($icon_file)) {
$feed->set([
'favicon_avg_color' => null,
'favicon_last_checked' => '1970-01-01',
'favicon_is_custom' => false,
]);
$feed->save();
}
}
}
function uploadicon() {
header("Content-type: text/html");
if (is_uploaded_file($_FILES['icon_file']['tmp_name'])) {
$tmp_file = tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'icon');
if (!$tmp_file)
return;
$result = move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file);
function uploadIcon() {
$feed_id = (int) $_REQUEST['feed_id'];
$tmp_file = tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'icon');
if (!$result) {
return;
}
} else {
return;
}
$icon_file = $tmp_file;
$feed_id = clean($_REQUEST["feed_id"]);
$rc = 2; // failed
// default value
$rc = self::E_ICON_UPLOAD_FAILED;
if ($icon_file && is_file($icon_file) && $feed_id) {
if (filesize($icon_file) < 65535) {
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->find_one($feed_id);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
if ($feed && $tmp_file && move_uploaded_file($_FILES['icon_file']['tmp_name'], $tmp_file)) {
if (filesize($tmp_file) < Config::get(Config::MAX_FAVICON_FILE_SIZE)) {
if ($row = $sth->fetch()) {
$new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico";
$new_filename = Config::get(Config::ICONS_DIR) . "/$feed_id.ico";
if (file_exists($new_filename)) unlink($new_filename);
if (file_exists($new_filename)) unlink($new_filename);
if (rename($tmp_file, $new_filename)) {
chmod($new_filename, 0644);
if (rename($icon_file, $new_filename)) {
chmod($new_filename, 644);
$feed->set([
'favicon_avg_color' => null,
'favicon_is_custom' => true,
]);
$sth = $this->pdo->prepare("UPDATE ttrss_feeds SET
favicon_avg_color = ''
WHERE id = ?");
$sth->execute([$feed_id]);
if ($feed->save()) {
$rc = self::E_ICON_UPLOAD_SUCCESS;
}
$rc = Feeds::_get_icon($feed_id);
} else {
$rc = self::E_ICON_RENAME_FAILED;
}
}
} else {
$rc = 1;
$rc = self::E_ICON_FILE_TOO_LARGE;
}
}
if ($icon_file && is_file($icon_file)) {
unlink($icon_file);
}
if (file_exists($tmp_file))
unlink($tmp_file);
print $rc;
return;
print json_encode(['rc' => $rc, 'icon_url' => Feeds::_get_icon($feed_id)]);
}
function editfeed() {
@ -696,7 +689,7 @@ class Pref_Feeds extends Handler_Protected {
$purge_intl = (int) clean($_POST["purge_interval"] ?? 0);
$feed_id = (int) clean($_POST["id"] ?? 0); /* editSave */
$feed_ids = explode(",", clean($_POST["ids"] ?? "")); /* batchEditSave */
$cat_id = (int) clean($_POST["cat_id"]);
$cat_id = (int) clean($_POST["cat_id"] ?? 0);
$auth_login = clean($_POST["auth_login"]);
$auth_pass = clean($_POST["auth_pass"]);
$private = checkbox_to_sql_bool(clean($_POST["private"] ?? ""));
@ -989,10 +982,6 @@ class Pref_Feeds extends Handler_Protected {
private function index_opml() {
?>
<h3><?= __("Using OPML you can export and import your feeds, filters, labels and Tiny Tiny RSS settings.") ?></h3>
<?php print_notice("Only main settings profile can be migrated using OPML.") ?>
<form id='opml_import_form' method='post' enctype='multipart/form-data'>
<label class='dijitButton'><?= __("Choose file...") ?>
<input style='display : none' id='opml_file' name='opml_file' type='file'>
@ -1001,36 +990,27 @@ class Pref_Feeds extends Handler_Protected {
<input type='hidden' name='csrf_token' value="<?= $_SESSION['csrf_token'] ?>">
<input type='hidden' name='method' value='importOpml'>
<button dojoType='dijit.form.Button' class='alt-primary' onclick="return Helpers.OPML.import()" type="submit">
<?= \Controls\icon("file_upload") ?>
<?= __('Import OPML') ?>
</button>
</form>
<hr/>
<?php print_notice("Only main settings profile can be migrated using OPML.") ?>
<form dojoType='dijit.form.Form' id='opmlExportForm' style='display : inline-block'>
<button dojoType='dijit.form.Button' onclick='Helpers.OPML.export()'>
<?= \Controls\icon("file_download") ?>
<?= __('Export OPML') ?>
</button>
<label class='checkbox'>
<?= \Controls\checkbox_tag("include_settings", true, "1") ?>
<?= __("Include settings") ?>
<?= __("Include tt-rss settings") ?>
</label>
</form>
<hr/>
<h2><?= __("Published OPML") ?></h2>
<p>
<?= __('Your OPML can be published publicly and can be subscribed by anyone who knows the URL below.') ?>
<?= __("Published OPML does not include your Tiny Tiny RSS settings, feeds that require authentication or feeds hidden from Popular feeds.") ?>
</p>
<button dojoType='dijit.form.Button' class='alt-primary' onclick="return Helpers.OPML.publish()">
<?= __('Display published OPML URL') ?>
</button>
<?php
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefFeedsOPML");
}
@ -1038,14 +1018,16 @@ class Pref_Feeds extends Handler_Protected {
private function index_shared() {
?>
<h3><?= __('Published articles can be subscribed by anyone who knows the following URL:') ?></h3>
<?= format_notice('Published articles can be subscribed by anyone who knows the following URL:') ?></h3>
<button dojoType='dijit.form.Button' class='alt-primary'
onclick="CommonDialogs.generatedFeed(-2, false)">
<?= \Controls\icon('share') ?>
<?= __('Display URL') ?>
</button>
<button class='alt-danger' dojoType='dijit.form.Button' onclick='return Helpers.Feeds.clearFeedAccessKeys()'>
<?= \Controls\icon('delete') ?>
<?= __('Clear all generated URLs') ?>
</button>
@ -1137,41 +1119,38 @@ class Pref_Feeds extends Handler_Protected {
$interval_qpart = "DATE_SUB(NOW(), INTERVAL 3 MONTH)";
}
$sth = $this->pdo->prepare("SELECT ttrss_feeds.title, ttrss_feeds.site_url,
ttrss_feeds.feed_url, ttrss_feeds.id, MAX(updated) AS last_article
FROM ttrss_feeds, ttrss_entries, ttrss_user_entries WHERE
(SELECT MAX(updated) FROM ttrss_entries, ttrss_user_entries WHERE
ttrss_entries.id = ref_id AND
ttrss_user_entries.feed_id = ttrss_feeds.id) < $interval_qpart
AND ttrss_feeds.owner_uid = ? AND
ttrss_user_entries.feed_id = ttrss_feeds.id AND
ttrss_entries.id = ref_id
GROUP BY ttrss_feeds.title, ttrss_feeds.id, ttrss_feeds.site_url, ttrss_feeds.feed_url
ORDER BY last_article");
$sth->execute([$_SESSION['uid']]);
$rv = [];
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$row['last_article'] = TimeHelper::make_local_datetime($row['last_article'], false);
array_push($rv, $row);
$inactive_feeds = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('f.id', 'f.title', 'f.site_url', 'f.feed_url')
->select_expr('MAX(e.updated)', 'last_article')
->join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->join('ttrss_entries', ['e.id', '=', 'ue.ref_id'], 'e')
->where('f.owner_uid', $_SESSION['uid'])
->where_raw(
"(SELECT MAX(ttrss_entries.updated)
FROM ttrss_entries
JOIN ttrss_user_entries ON ttrss_entries.id = ttrss_user_entries.ref_id
WHERE ttrss_user_entries.feed_id = f.id) < $interval_qpart")
->group_by('f.title')
->group_by('f.id')
->group_by('f.site_url')
->group_by('f.feed_url')
->order_by_asc('last_article')
->find_array();
foreach ($inactive_feeds as $inactive_feed) {
$inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article'], false);
}
print json_encode($rv);
print json_encode($inactive_feeds);
}
function feedsWithErrors() {
$sth = $this->pdo->prepare("SELECT id,title,feed_url,last_error,site_url
FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$rv = [];
while ($row = $sth->fetch()) {
array_push($rv, $row);
}
print json_encode($rv);
print json_encode(ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'feed_url', 'last_error', 'site_url')
->where_not_equal('last_error', '')
->where('owner_uid', $_SESSION['uid'])
->find_array());
}
static function remove_feed($id, $owner_uid) {
@ -1257,17 +1236,6 @@ class Pref_Feeds extends Handler_Protected {
return Feeds::_clear_access_keys($_SESSION['uid']);
}
function getOPMLKey() {
print json_encode(["link" => OPML::get_publish_url()]);
}
function regenOPMLKey() {
Feeds::_update_access_key('OPML:Publish',
false, $_SESSION["uid"]);
print json_encode(["link" => OPML::get_publish_url()]);
}
function regenFeedKey() {
$feed_id = clean($_REQUEST['id']);
$is_cat = clean($_REQUEST['is_cat']);
@ -1300,7 +1268,7 @@ class Pref_Feeds extends Handler_Protected {
$c = 0;
foreach ($cat['items'] ?? [] as $child) {
if ($child['type'] ?? '' == 'category') {
if (($child['type'] ?? '') == 'category') {
$c += $this->calculate_children_count($child);
} else {
$c += 1;

@ -51,8 +51,8 @@ class Pref_Filters extends Handler_Protected {
$filter = array();
$filter["enabled"] = true;
$filter["match_any_rule"] = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"]));
$filter["inverse"] = checkbox_to_sql_bool(clean($_REQUEST["inverse"]));
$filter["match_any_rule"] = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false));
$filter["inverse"] = checkbox_to_sql_bool(clean($_REQUEST["inverse"] ?? false));
$filter["rules"] = array();
$filter["actions"] = array("dummy-action");
@ -203,7 +203,7 @@ class Pref_Filters extends Handler_Protected {
} else {
$where = $line["cat_filter"] ?
Feeds::_get_cat_title($line["cat_id"]) :
Feeds::_get_cat_title($line["cat_id"] ?? 0) :
($line["feed_id"] ?
Feeds::_get_title($line["feed_id"]) : __("All feeds"));
}

@ -160,7 +160,7 @@ class Pref_Labels extends Handler_Protected {
function add() {
$caption = clean($_REQUEST["caption"]);
$output = clean($_REQUEST["output"]);
$output = clean($_REQUEST["output"] ?? false);
if ($caption) {
if (Labels::create($caption)) {

@ -8,6 +8,15 @@ class Pref_Prefs extends Handler_Protected {
private $pref_help_bottom = [];
private $pref_blacklist = [];
const PI_RES_ALREADY_INSTALLED = "PI_RES_ALREADY_INSTALLED";
const PI_RES_SUCCESS = "PI_RES_SUCCESS";
const PI_ERR_NO_CLASS = "PI_ERR_NO_CLASS";
const PI_ERR_NO_INIT_PHP = "PI_ERR_NO_INIT_PHP";
const PI_ERR_EXEC_FAILED = "PI_ERR_EXEC_FAILED";
const PI_ERR_NO_TEMPDIR = "PI_ERR_NO_TEMPDIR";
const PI_ERR_PLUGIN_NOT_FOUND = "PI_ERR_PLUGIN_NOT_FOUND";
const PI_ERR_NO_WORKDIR = "PI_ERR_NO_WORKDIR";
function csrf_ignore($method) {
$csrf_ignored = array("index", "updateself", "otpqrcode");
@ -45,6 +54,7 @@ class Pref_Prefs extends Handler_Protected {
'BLOCK_SEPARATOR',
Prefs::COMBINED_DISPLAY_MODE,
Prefs::CDM_EXPANDED,
Prefs::CDM_ENABLE_GRID,
'BLOCK_SEPARATOR',
Prefs::CDM_AUTO_CATCHUP,
Prefs::VFEED_GROUP_BY_FEED,
@ -108,6 +118,7 @@ class Pref_Prefs extends Handler_Protected {
Prefs::HEADLINES_NO_DISTINCT => array(__("Don't enforce DISTINCT headlines"), __("May produce duplicate entries")),
Prefs::DEBUG_HEADLINE_IDS => array(__("Show article and feed IDs"), __("In the headlines buffer")),
Prefs::DISABLE_CONDITIONAL_COUNTERS => array(__("Disable conditional counter updates"), __("May increase server load")),
Prefs::CDM_ENABLE_GRID => array(__("Grid view"), __("On wider screens, if always expanded")),
];
// hidden in the main prefs UI (use to hide things that have description set above)
@ -220,29 +231,29 @@ class Pref_Prefs extends Handler_Protected {
if ($user) {
$user->full_name = clean($_POST['full_name']);
if ($user->email != $new_email)
if ($user->email != $new_email) {
Logger::log(E_USER_NOTICE, "Email address of user ".$user->login." has been changed to ${new_email}.");
if ($user->email && $user->email != $new_email) {
$mailer = new Mailer();
if ($user->email) {
$mailer = new Mailer();
$tpl = new Templator();
$tpl = new Templator();
$tpl->readTemplateFromFile("mail_change_template.txt");
$tpl->readTemplateFromFile("mail_change_template.txt");
$tpl->setVariable('LOGIN', $user->login);
$tpl->setVariable('NEWMAIL', $new_email);
$tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$tpl->setVariable('LOGIN', $user->login);
$tpl->setVariable('NEWMAIL', $new_email);
$tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$tpl->addBlock('message');
$tpl->addBlock('message');
$tpl->generateOutputToString($message);
$tpl->generateOutputToString($message);
$mailer->mail(["to_name" => $user->login,
"to_address" => $user->email,
"subject" => "[tt-rss] Email address change notification",
"message" => $message]);
$mailer->mail(["to_name" => $user->login,
"to_address" => $user->email,
"subject" => "[tt-rss] Email address change notification",
"message" => $message]);
}
$user->email = $new_email;
}
@ -292,7 +303,8 @@ class Pref_Prefs extends Handler_Protected {
<hr/>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
<?= __("Save data") ?>
<?= \Controls\icon("save") ?>
<?= __("Save") ?>
</button>
</form>
<?php
@ -342,10 +354,6 @@ class Pref_Prefs extends Handler_Protected {
}
</script>
<?php if ($otp_enabled) {
print_notice(__("Changing your current password will disable OTP."));
} ?>
<fieldset>
<label><?= __("Old password:") ?></label>
<input dojoType='dijit.form.ValidationTextBox' type='password' required='1' name='old_password'>
@ -364,6 +372,7 @@ class Pref_Prefs extends Handler_Protected {
<hr/>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
<?= \Controls\icon("security") ?>
<?= __("Change password") ?>
</button>
</form>
@ -377,7 +386,7 @@ class Pref_Prefs extends Handler_Protected {
}
private function index_auth_app_passwords() {
print_notice("You can create separate passwords for API clients. Using one is required if you enable OTP.");
print_notice("Separate passwords used for API clients. Required if you enable OTP.");
?>
<div id='app_passwords_holder'>
@ -387,12 +396,14 @@ class Pref_Prefs extends Handler_Protected {
<hr>
<button style='float : left' class='alt-primary' dojoType='dijit.form.Button' onclick="Helpers.AppPasswords.generate()">
<?= __('Generate new password') ?>
<?= \Controls\icon("add") ?>
<?= __('Generate password') ?>
</button>
<button style='float : left' class='alt-danger' dojoType='dijit.form.Button'
onclick="Helpers.AppPasswords.removeSelected()">
<?= __('Remove selected passwords') ?>
<?= \Controls\icon("delete") ?>
<?= __('Remove selected') ?>
</button>
<?php
@ -435,6 +446,7 @@ class Pref_Prefs extends Handler_Protected {
<hr/>
<button dojoType='dijit.form.Button' type='submit' class='alt-danger'>
<?= \Controls\icon("lock_open") ?>
<?= __("Disable OTP") ?>
</button>
@ -444,16 +456,9 @@ class Pref_Prefs extends Handler_Protected {
} else {
print_warning("You will need a compatible Authenticator to use this. Changing your password would automatically disable OTP.");
print_notice("You will need to generate app passwords for the API clients if you enable OTP.");
print "<img src=".($this->_get_otp_qrcode_img()).">";
if (function_exists("imagecreatefromstring")) {
print "<h3>" . __("Scan the following code by the Authenticator application or copy the key manually") . "</h3>";
print "<img src=".($this->_get_otp_qrcode_img()).">";
} else {
print_error("PHP GD functions are required to generate QR codes.");
print "<h3>" . __("Use the following OTP key with a compatible Authenticator application") . "</h3>";
}
print_notice("You will need to generate app passwords for API clients if you enable OTP.");
$otp_secret = UserHelper::get_otp_secret($_SESSION["uid"]);
?>
@ -464,8 +469,8 @@ class Pref_Prefs extends Handler_Protected {
<?= \Controls\hidden_tag("method", "otpenable") ?>
<fieldset>
<label><?= __("OTP Key:") ?></label>
<input dojoType='dijit.form.ValidationTextBox' disabled='disabled' value="<?= $otp_secret ?>" size='32'>
<label><?= __("OTP secret:") ?></label>
<code><?= $this->format_otp_secret($otp_secret) ?></code>
</fieldset>
<!-- TODO: return JSON from the backend call -->
@ -491,13 +496,14 @@ class Pref_Prefs extends Handler_Protected {
</fieldset>
<fieldset>
<label><?= __("One time password:") ?></label>
<label><?= __("Verification code:") ?></label>
<input dojoType='dijit.form.ValidationTextBox' autocomplete='off' required='1' name='otp'>
</fieldset>
<hr/>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
<?= \Controls\icon("lock") ?>
<?= __("Enable OTP") ?>
</button>
@ -614,30 +620,27 @@ class Pref_Prefs extends Handler_Protected {
} else if ($pref_name == "USER_CSS_THEME") {
$themes = array_merge(glob("themes/*.php"), glob("themes/*.css"), glob("themes.local/*.css"));
$themes = array_map("basename", $themes);
$themes = array_filter($themes, "theme_exists");
asort($themes);
if (!theme_exists($value)) $value = "";
$theme_files = array_map("basename",
array_merge(glob("themes/*.php"),
glob("themes/*.css"),
glob("themes.local/*.css")));
print "<select name='$pref_name' id='$pref_name' dojoType='fox.form.Select'>";
asort($theme_files);
$issel = $value == "" ? "selected='selected'" : "";
print "<option $issel value=''>".__("default")."</option>";
$themes = [ "" => __("default") ];
foreach ($themes as $theme) {
$issel = $value == $theme ? "selected='selected'" : "";
print "<option $issel value='$theme'>$theme</option>";
foreach ($theme_files as $file) {
$themes[$file] = basename($file, ".css");
}
?>
print "</select>";
<?= \Controls\select_hash($pref_name, $value, $themes) ?>
<?= \Controls\button_tag(\Controls\icon("palette") . " " . __("Customize"), "",
["onclick" => "Helpers.Prefs.customizeCSS()"]) ?>
<?= \Controls\button_tag(\Controls\icon("open_in_new") . " " . __("More themes..."), "",
["class" => "alt-info", "onclick" => "window.open(\"https://tt-rss.org/wiki/Themes\")"]) ?>
print " <button dojoType=\"dijit.form.Button\" class='alt-info'
onclick=\"Helpers.Prefs.customizeCSS()\">" . __('Customize') . "</button>";
print " <button dojoType='dijit.form.Button' onclick='window.open(\"https://tt-rss.org/wiki/Themes\")'>
<i class='material-icons'>open_in_new</i> ".__("More themes...")."</button>";
<?php
} else if ($pref_name == "DEFAULT_UPDATE_INTERVAL") {
@ -665,7 +668,8 @@ class Pref_Prefs extends Handler_Protected {
["disabled" => $is_disabled], "CB_$pref_name");
if ($pref_name == Prefs::DIGEST_ENABLE) {
print \Controls\button_tag(__('Preview'), '', ['onclick' => 'Helpers.Digest.preview()', 'style' => 'margin-left : 10px']);
print \Controls\button_tag(\Controls\icon("info") . " " . __('Preview'), '',
['onclick' => 'Helpers.Digest.preview()', 'style' => 'margin-left : 10px']);
}
} else if (in_array($pref_name, ['FRESH_ARTICLE_MAX_AGE',
@ -690,11 +694,11 @@ class Pref_Prefs extends Handler_Protected {
$cert_serial = htmlspecialchars(self::_get_ssl_certificate_id());
$has_serial = ($cert_serial) ? true : false;
print \Controls\button_tag(__('Register'), "", [
print \Controls\button_tag(\Controls\icon("security") . " " . __('Register'), "", [
"disabled" => !$has_serial,
"onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '$cert_serial')"]);
print \Controls\button_tag(__('Clear'), "", [
print \Controls\button_tag(\Controls\icon("clear") . " " . __('Clear'), "", [
"class" => "alt-danger",
"onclick" => "dijit.byId('SSL_CERT_SERIAL').attr('value', '')"]);
@ -754,19 +758,21 @@ class Pref_Prefs extends Handler_Protected {
<div dojoType="dijit.layout.ContentPane" region="bottom">
<div dojoType="fox.form.ComboButton" type="submit" class="alt-primary">
<span><?= __('Save configuration') ?></span>
<span> <?= __('Save configuration') ?></span>
<div dojoType="dijit.DropDownMenu">
<div dojoType="dijit.MenuItem" onclick="dijit.byId('changeSettingsForm').onSubmit(null, true)">
<?= __("Save and exit preferences") ?>
<?= __("Save and exit") ?>
</div>
</div>
</div>
<button dojoType="dijit.form.Button" onclick="return Helpers.Profiles.edit()">
<?= \Controls\icon("settings") ?>
<?= __('Manage profiles') ?>
</button>
<button dojoType="dijit.form.Button" class="alt-danger" onclick="return Helpers.Prefs.confirmReset()">
<?= \Controls\icon("clear") ?>
<?= __('Reset to defaults') ?>
</button>
@ -777,148 +783,73 @@ class Pref_Prefs extends Handler_Protected {
<?php
}
private function index_plugins_system() {
print_notice("System plugins are enabled in <strong>config.php</strong> for all users.");
$system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS)));
$tmppluginhost = new PluginHost();
$tmppluginhost->load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true);
foreach ($tmppluginhost->get_plugins() as $name => $plugin) {
$about = $plugin->about();
$version = htmlspecialchars($this->_get_plugin_version($plugin));
if ($about[3] ?? false) {
$is_checked = in_array($name, $system_enabled) ? "checked" : "";
?>
<fieldset class='prefs plugin'>
<label><?= $name ?>:</label>
<label class='checkbox description text-muted' id="PLABEL-<?= htmlspecialchars($name) ?>">
<input disabled='1' dojoType='dijit.form.CheckBox' <?= $is_checked ?> type='checkbox'><?= htmlspecialchars($about[1]) ?>
</label>
<?php if ($_SESSION["access_level"] >= 10) { ?>
<button style="display : none"
data-update-btn-for-plugin="<?= htmlspecialchars($name) ?>" dojoType='dijit.form.Button'
onclick='Helpers.Plugins.update("<?= htmlspecialchars($name) ?>")'>
<?= \Controls\icon("update") ?>
<?= __("Update") ?>
</button>
<?php } ?>
<?php if ($about[4] ?? false) { ?>
<button dojoType='dijit.form.Button' class='alt-info'
onclick='window.open("<?= htmlspecialchars($about[4]) ?>")'>
<i class='material-icons'>open_in_new</i> <?= __("More info...") ?></button>
<?php } ?>
<?php if ($version) { ?>
<div dojoType='dijit.Tooltip' connectId='PLABEL-<?= htmlspecialchars($name) ?>' position='after'>
<?= $version ?>
</div>
<?php } ?>
</fieldset>
<?php
}
}
}
private function index_plugins_user() {
function getPluginsList() {
$system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS)));
$user_enabled = array_map("trim", explode(",", get_pref(Prefs::_ENABLED_PLUGINS)));
$tmppluginhost = new PluginHost();
$tmppluginhost->load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true);
$rv = [];
foreach ($tmppluginhost->get_plugins() as $name => $plugin) {
$about = $plugin->about();
$is_local = $tmppluginhost->is_local($plugin);
$version = htmlspecialchars($this->_get_plugin_version($plugin));
if (empty($about[3]) || $about[3] == false) {
$is_checked = "";
$is_disabled = "";
if (in_array($name, $system_enabled)) {
$is_checked = "checked='1'";
$is_disabled = "disabled='1'";
} else if (in_array($name, $user_enabled)) {
$is_checked = "checked='1'";
}
?>
<fieldset class='prefs plugin'>
<label><?= $name ?>:</label>
<label class='checkbox description text-muted' id="PLABEL-<?= htmlspecialchars($name) ?>">
<input name='plugins[]' value="<?= htmlspecialchars($name) ?>"
dojoType='dijit.form.CheckBox' <?= $is_checked ?> <?= $is_disabled ?> type='checkbox'>
<?= htmlspecialchars($about[1]) ?>
</input>
</label>
<?php if ($_SESSION["access_level"] >= 10) { ?>
<button style="display : none"
data-update-btn-for-plugin="<?= htmlspecialchars($name) ?>" dojoType='dijit.form.Button'
onclick='Helpers.Plugins.update("<?= htmlspecialchars($name) ?>")'>
<?= \Controls\icon("update") ?>
<?= __("Update") ?>
</button>
<?php } ?>
<?php if (count($tmppluginhost->get_all($plugin)) > 0) {
if (in_array($name, $system_enabled) || in_array($name, $user_enabled)) { ?>
<button dojoType='dijit.form.Button'
onclick='Helpers.Plugins.clearPluginData("<?= htmlspecialchars($name) ?>")'>
<i class='material-icons'>clear</i> <?= __("Clear data") ?></button>
<?php }
} ?>
<?php if ($about[4] ?? false) { ?>
<button dojoType='dijit.form.Button' class='alt-info'
onclick='window.open("<?= htmlspecialchars($about[4]) ?>")'>
<i class='material-icons'>open_in_new</i> <?= __("More info...") ?></button>
<?php } ?>
array_push($rv, [
"name" => $name,
"is_local" => $is_local,
"system_enabled" => in_array($name, $system_enabled),
"user_enabled" => in_array($name, $user_enabled),
"has_data" => count($tmppluginhost->get_all($plugin)) > 0,
"is_system" => (bool)($about[3] ?? false),
"version" => $version,
"author" => $about[2] ?? "",
"description" => $about[1] ?? "",
"more_info" => $about[4] ?? "",
]);
}
<?php if ($version) { ?>
<div dojoType='dijit.Tooltip' connectId='PLABEL-<?= htmlspecialchars($name) ?>' position='after'>
<?= $version ?>
</div>
<?php } ?>
usort($rv, function($a, $b) { return strcmp($a["name"], $b["name"]); });
</fieldset>
<?php
}
}
print json_encode(['plugins' => $rv, 'is_admin' => $_SESSION['access_level'] >= 10]);
}
function index_plugins() {
?>
<form dojoType="dijit.form.Form" id="changePluginsForm">
<script type="dojo/method" event="onSubmit" args="evt">
evt.preventDefault();
if (this.validate()) {
xhr.post("backend.php", this.getValues(), () => {
Notify.close();
if (confirm(__('Selected plugins have been enabled. Reload?'))) {
window.location.reload();
}
})
}
</script>
<?php if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10) { ?>
<script type="dojo/method" event="onShow" args="evt">
Helpers.Plugins.checkForUpdate();
</script>
<?php } ?>
<?= \Controls\hidden_tag("op", "pref-prefs") ?>
<?= \Controls\hidden_tag("method", "setplugins") ?>
<div dojoType="dijit.layout.BorderContainer" gutters="false">
<div region="top" dojoType='fox.Toolbar'>
<div class='pull-right'>
<input name="search" type="search" onkeyup='Helpers.Plugins.search()' dojoType="dijit.form.TextBox">
<button dojoType='dijit.form.Button' onclick='Helpers.Plugins.search()'>
<?= __('Search') ?>
</button>
</div>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Lists.select('prefs-plugin-list', true)"
dojoType='dijit.MenuItem'><?= __('All') ?></div>
<div onclick="Lists.select('prefs-plugin-list', false)"
dojoType='dijit.MenuItem'><?= __('None') ?></div>
</div>
</div>
</div>
<div dojoType="dijit.layout.ContentPane" region="center" style="overflow-y : auto">
<?php
<script type="dojo/method" event="onShow">
Helpers.Plugins.reload();
</script>
<!-- <?php
if (!empty($_SESSION["safe_mode"])) {
print_error("You have logged in using safe mode, no user plugins will be actually enabled until you login again.");
}
@ -940,29 +871,40 @@ class Pref_Prefs extends Handler_Protected {
) . " (<a href='https://tt-rss.org/wiki/FeedHandlerPlugins' target='_blank'>".__("More info...")."</a>)"
);
}
?>
<h2><?= __("System plugins") ?></h2>
<?php $this->index_plugins_system() ?>
?> -->
<h2><?= __("User plugins") ?></h2>
<?php $this->index_plugins_user() ?>
<ul id="prefs-plugin-list" class="prefs-plugin-list list-unstyled">
<li class='text-center'><?= __("Loading, please wait...") ?></li>
</ul>
</div>
<div dojoType="dijit.layout.ContentPane" region="bottom">
<button dojoType='dijit.form.Button' class="alt-info pull-left" onclick='window.open("https://tt-rss.org/wiki/Plugins")'>
<i class='material-icons'>help</i> <?= __("More info...") ?>
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
<?= __("Enable selected plugins") ?>
<button dojoType='dijit.form.Button' class="alt-info pull-right" onclick='window.open("https://tt-rss.org/wiki/Plugins")'>
<i class='material-icons'>help</i>
<?= __("More info") ?>
</button>
<?= \Controls\button_tag(\Controls\icon("check") . " " .__("Enable selected"), "", ["class" => "alt-primary",
"onclick" => "Helpers.Plugins.enableSelected()"]) ?>
<?= \Controls\button_tag(\Controls\icon("refresh"), "", ["title" => __("Reload"), "onclick" => "Helpers.Plugins.reload()"]) ?>
<?php if ($_SESSION["access_level"] >= 10) { ?>
<button class="update-all-plugins-btn" style="display : none" dojoType='dijit.form.Button' onclick="Helpers.Plugins.update()">
<?= \Controls\icon("update") ?>
<?= __("Update local plugins") ?>
</button>
<?php if (Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { ?>
<button class='alt-warning' dojoType='dijit.form.Button' onclick="Helpers.Plugins.update()">
<?= \Controls\icon("update") ?>
<?= __("Check for updates") ?>
</button>
<?php } ?>
<?php if (Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { ?>
<button dojoType='dijit.form.Button' onclick="Helpers.Plugins.install()">
<?= \Controls\icon("add") ?>
<?= __("Install plugin") ?>
</button>
<?php } ?>
<?php } ?>
</div>
</div>
@ -987,16 +929,8 @@ class Pref_Prefs extends Handler_Protected {
<div dojoType='dijit.layout.AccordionPane' selected='true' title="<i class='material-icons'>settings</i> <?= __('Preferences') ?>">
<?php $this->index_prefs() ?>
</div>
<div dojoType='dijit.layout.AccordionPane' title="<i class='material-icons'>extension</i> <?= __('Plugins') ?>">
<script type='dojo/method' event='onSelected' args='evt'>
if (this.domNode.querySelector('.loading'))
window.setTimeout(() => {
xhr.post("backend.php", {op: 'pref-prefs', method: 'index_plugins'}, (reply) => {
this.attr('content', reply);
});
}, 200);
</script>
<span class='loading'><?= __("Loading, please wait...") ?></span>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title="<i class='material-icons'>extension</i> <?= __('Plugins') ?>">
<?php $this->index_plugins() ?>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefPrefs") ?>
</div>
@ -1075,12 +1009,9 @@ class Pref_Prefs extends Handler_Protected {
}
function setplugins() {
if (is_array(clean($_REQUEST["plugins"])))
$plugins = join(",", clean($_REQUEST["plugins"]));
else
$plugins = "";
$plugins = array_filter($_REQUEST["plugins"], 'clean') ?? [];
set_pref(Prefs::_ENABLED_PLUGINS, $plugins);
set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins));
}
function _get_plugin_version(Plugin $plugin) {
@ -1120,7 +1051,7 @@ class Pref_Prefs extends Handler_Protected {
}
$rv = array_values(array_filter($rv, function ($item) {
return !empty($item["rv"]["o"]);
return $item["rv"]["need_update"];
}));
return $rv;
@ -1128,7 +1059,7 @@ class Pref_Prefs extends Handler_Protected {
private static function _plugin_needs_update($root_dir, $plugin_name) {
$plugin_dir = "$root_dir/plugins.local/" . basename($plugin_name);
$rv = [];
$rv = null;
if (is_dir($plugin_dir) && is_dir("$plugin_dir/.git")) {
$pipes = [];
@ -1142,10 +1073,12 @@ class Pref_Prefs extends Handler_Protected {
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv["o"] = stream_get_contents($pipes[1]);
$rv["e"] = stream_get_contents($pipes[2]);
$status = proc_close($proc);
$rv["s"] = $status;
$rv = [
"stdout" => stream_get_contents($pipes[1]),
"stderr" => stream_get_contents($pipes[2]),
"git_status" => proc_close($proc),
];
$rv["need_update"] = !empty($rv["stdout"]);
}
}
@ -1169,18 +1102,168 @@ class Pref_Prefs extends Handler_Protected {
$proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv["o"] = stream_get_contents($pipes[1]);
$rv["e"] = stream_get_contents($pipes[2]);
$status = proc_close($proc);
$rv["s"] = $status;
$rv["stdout"] = stream_get_contents($pipes[1]);
$rv["stderr"] = stream_get_contents($pipes[2]);
$rv["git_status"] = proc_close($proc);
}
}
return $rv;
}
function checkForPluginUpdates() {
// https://gist.github.com/mindplay-dk/a4aad91f5a4f1283a5e2#gistcomment-2036828
private function _recursive_rmdir(string $dir, bool $keep_root = false) {
// Handle bad arguments.
if (empty($dir) || !file_exists($dir)) {
return true; // No such file/dir$dir exists.
} elseif (is_file($dir) || is_link($dir)) {
return unlink($dir); // Delete file/link.
}
// Delete all children.
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $fileinfo) {
$action = $fileinfo->isDir() ? 'rmdir' : 'unlink';
if (!$action($fileinfo->getRealPath())) {
return false; // Abort due to the failure.
}
}
return $keep_root ? true : rmdir($dir);
}
// https://stackoverflow.com/questions/7153000/get-class-name-from-file
private function _get_class_name_from_file($file) {
$tokens = token_get_all(file_get_contents($file));
for ($i = 0; $i < count($tokens); $i++) {
if (isset($tokens[$i][0]) && $tokens[$i][0] == T_CLASS) {
for ($j = $i+1; $j < count($tokens); $j++) {
if (isset($tokens[$j][1]) && $tokens[$j][1] != " ") {
return $tokens[$j][1];
}
}
}
}
}
function uninstallPlugin() {
if ($_SESSION["access_level"] >= 10) {
$plugin_name = basename(clean($_REQUEST['plugin']));
$status = 0;
$plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local/$plugin_name";
if (is_dir($plugin_dir)) {
$status = $this->_recursive_rmdir($plugin_dir);
}
print json_encode(['status' => $status]);
}
}
function installPlugin() {
if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) {
$plugin_name = basename(clean($_REQUEST['plugin']));
$all_plugins = $this->_get_available_plugins();
$plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local";
$work_dir = "$plugin_dir/plugin-installer";
$rv = [ ];
if (is_dir($work_dir) || mkdir($work_dir)) {
foreach ($all_plugins as $plugin) {
if ($plugin['name'] == $plugin_name) {
$tmp_dir = tempnam($work_dir, $plugin_name);
if (file_exists($tmp_dir)) {
unlink($tmp_dir);
$pipes = [];
$descriptorspec = [
1 => ["pipe", "w"], // STDOUT
2 => ["pipe", "w"], // STDERR
];
$proc = proc_open("git clone " . escapeshellarg($plugin['clone_url']) . " " . $tmp_dir,
$descriptorspec, $pipes, sys_get_temp_dir());
$status = 0;
if (is_resource($proc)) {
$rv["stdout"] = stream_get_contents($pipes[1]);
$rv["stderr"] = stream_get_contents($pipes[2]);
$status = proc_close($proc);
$rv["git_status"] = $status;
// yeah I know about mysterious RC = -1
if (file_exists("$tmp_dir/init.php")) {
$class_name = strtolower(basename($this->_get_class_name_from_file("$tmp_dir/init.php")));
if ($class_name) {
$dst_dir = "$plugin_dir/$class_name";
if (is_dir($dst_dir)) {
$rv['result'] = self::PI_RES_ALREADY_INSTALLED;
} else {
if (rename($tmp_dir, "$plugin_dir/$class_name")) {
$rv['result'] = self::PI_RES_SUCCESS;
}
}
} else {
$rv['result'] = self::PI_ERR_NO_CLASS;
}
} else {
$rv['result'] = self::PI_ERR_NO_INIT_PHP;
}
} else {
$rv['result'] = self::PI_ERR_EXEC_FAILED;
}
} else {
$rv['result'] = self::PI_ERR_NO_TEMPDIR;
}
// cleanup after failure
if ($tmp_dir && is_dir($tmp_dir)) {
$this->_recursive_rmdir($tmp_dir);
}
break;
}
}
if (empty($rv['result']))
$rv['result'] = self::PI_ERR_PLUGIN_NOT_FOUND;
} else {
$rv["result"] = self::PI_ERR_NO_WORKDIR;
}
print json_encode($rv);
}
}
private function _get_available_plugins() {
if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) {
return json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true);
}
}
function getAvailablePlugins() {
if ($_SESSION["access_level"] >= 10) {
print json_encode($this->_get_available_plugins());
}
}
function checkForPluginUpdates() {
if ($_SESSION["access_level"] >= 10 && Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) {
$plugin_name = $_REQUEST["name"] ?? "";
$root_dir = dirname(dirname(__DIR__)); # we're in classes/pref/
@ -1242,66 +1325,90 @@ class Pref_Prefs extends Handler_Protected {
}
function activateprofile() {
$_SESSION["profile"] = (int) clean($_REQUEST["id"]);
$id = (int) $_REQUEST['id'] ?? 0;
// default value
if (!$_SESSION["profile"]) $_SESSION["profile"] = null;
}
$profile = ORM::for_table('ttrss_settings_profiles')
->where('owner_uid', $_SESSION['uid'])
->find_one($id);
function remprofiles() {
$ids = explode(",", clean($_REQUEST["ids"]));
if ($profile) {
$_SESSION["profile"] = $id;
} else {
$_SESSION["profile"] = null;
}
}
foreach ($ids as $id) {
if ($_SESSION["profile"] != $id) {
$sth = $this->pdo->prepare("DELETE FROM ttrss_settings_profiles WHERE id = ? AND
owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
function cloneprofile() {
$old_profile = $_REQUEST["old_profile"] ?? 0;
$new_title = clean($_REQUEST["new_title"]);
if ($old_profile && $new_title) {
$new_profile = ORM::for_table('ttrss_settings_profiles')->create();
$new_profile->title = $new_title;
$new_profile->owner_uid = $_SESSION['uid'];
if ($new_profile->save()) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs
(owner_uid, pref_name, profile, value)
SELECT
:uid,
pref_name,
:new_profile,
value
FROM ttrss_user_prefs
WHERE owner_uid = :uid AND profile = :old_profile");
$sth->execute([
"uid" => $_SESSION["uid"],
"new_profile" => $new_profile->id,
"old_profile" => $old_profile,
]);
}
}
}
function remprofiles() {
$ids = $_REQUEST["ids"] ?? [];
ORM::for_table('ttrss_settings_profiles')
->where('owner_uid', $_SESSION['uid'])
->where_in('id', $ids)
->where_not_equal('id', $_SESSION['profile'] ?? 0)
->delete_many();
}
function addprofile() {
$title = clean($_REQUEST["title"]);
if ($title) {
$this->pdo->beginTransaction();
$sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles
WHERE title = ? AND owner_uid = ?");
$sth->execute([$title, $_SESSION['uid']]);
$profile = ORM::for_table('ttrss_settings_profiles')
->where('owner_uid', $_SESSION['uid'])
->where('title', $title)
->find_one();
if (!$sth->fetch()) {
if (!$profile) {
$profile = ORM::for_table('ttrss_settings_profiles')->create();
$sth = $this->pdo->prepare("INSERT INTO ttrss_settings_profiles (title, owner_uid)
VALUES (?, ?)");
$sth->execute([$title, $_SESSION['uid']]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_settings_profiles WHERE
title = ? AND owner_uid = ?");
$sth->execute([$title, $_SESSION['uid']]);
$profile->title = $title;
$profile->owner_uid = $_SESSION['uid'];
$profile->save();
}
$this->pdo->commit();
}
}
function saveprofile() {
$id = clean($_REQUEST["id"]);
$title = clean($_REQUEST["title"]);
if ($id == 0) {
print __("Default profile");
return;
}
$id = (int)$_REQUEST["id"];
$title = clean($_REQUEST["value"]);
if ($title) {
$sth = $this->pdo->prepare("UPDATE ttrss_settings_profiles
SET title = ? WHERE id = ? AND
owner_uid = ?");
if ($title && $id) {
$profile = ORM::for_table('ttrss_settings_profiles')
->where('owner_uid', $_SESSION['uid'])
->find_one($id);
$sth->execute([$title, $id, $_SESSION['uid']]);
print $title;
if ($profile) {
$profile->title = $title;
$profile->save();
}
}
}
@ -1309,18 +1416,27 @@ class Pref_Prefs extends Handler_Protected {
function getProfiles() {
$rv = [];
$sth = $this->pdo->prepare("SELECT title,id FROM ttrss_settings_profiles
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$_SESSION['uid']]);
$profiles = ORM::for_table('ttrss_settings_profiles')
->where('owner_uid', $_SESSION['uid'])
->order_by_expr('title')
->find_many();
array_push($rv, ["title" => __("Default profile"),
"id" => 0,
"initialized" => true,
"active" => empty($_SESSION["profile"])
]);
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$row["active"] = isset($_SESSION["profile"]) && $_SESSION["profile"] == $row["id"];
array_push($rv, $row);
foreach ($profiles as $profile) {
$profile['active'] = ($_SESSION["profile"] ?? 0) == $profile->id;
$num_settings = ORM::for_table('ttrss_user_prefs')
->where('profile', $profile->id)
->count();
$profile['initialized'] = $num_settings > 0;
array_push($rv, $profile->as_array());
};
print json_encode($rv);
@ -1357,10 +1473,10 @@ class Pref_Prefs extends Handler_Protected {
<div class='panel panel-scrollable'>
<table width='100%' id='app-password-list'>
<tr>
<th width='2%'> </th>
<th align='left'><?= __("Description") ?></th>
<th align='right'><?= __("Created") ?></th>
<th align='right'><?= __("Last used") ?></th>
<th class="checkbox"> </th>
<th width='50%'><?= __("Description") ?></th>
<th><?= __("Created") ?></th>
<th><?= __("Last used") ?></th>
</tr>
<?php
@ -1371,16 +1487,16 @@ class Pref_Prefs extends Handler_Protected {
foreach ($passwords as $pass) { ?>
<tr data-row-id='<?= $pass['id'] ?>'>
<td align='center'>
<td class="checkbox">
<input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td>
<?= htmlspecialchars($pass["title"]) ?>
</td>
<td align='right' class='text-muted'>
<td class='text-muted'>
<?= TimeHelper::make_local_datetime($pass['created'], false) ?>
</td>
<td align='right' class='text-muted'>
<td class='text-muted'>
<?= TimeHelper::make_local_datetime($pass['last_used'], false) ?>
</td>
</tr>
@ -1439,4 +1555,8 @@ class Pref_Prefs extends Handler_Protected {
}
return "";
}
private function format_otp_secret($secret) {
return implode(" ", str_split($secret, 4));
}
}

@ -42,10 +42,10 @@ class Pref_System extends Handler_Administrative {
switch ($severity) {
case E_USER_ERROR:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE ];
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ];
break;
case E_USER_WARNING:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
break;
}
@ -117,12 +117,12 @@ class Pref_System extends Handler_Administrative {
<table width='100%' class='event-log'>
<tr class='title'>
<td width='5%'><?= __("Error") ?></td>
<td><?= __("Filename") ?></td>
<td><?= __("Message") ?></td>
<td width='5%'><?= __("User") ?></td>
<td width='5%'><?= __("Date") ?></td>
<tr>
<th width='5%'><?= __("Error") ?></th>
<th><?= __("Filename") ?></th>
<th><?= __("Message") ?></th>
<th width='5%'><?= __("User") ?></th>
<th width='5%'><?= __("Date") ?></th>
</tr>
<?php
@ -165,16 +165,13 @@ class Pref_System extends Handler_Administrative {
$page = (int) ($_REQUEST["page"] ?? 0);
?>
<div dojoType='dijit.layout.AccordionContainer' region='center'>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'>
<?php
if (Config::get(Config::LOG_DESTINATION) == "sql") {
<?php if (Config::get(Config::LOG_DESTINATION) == Logger::LOG_DEST_SQL) { ?>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'>
<?php
$this->_log_viewer($page, $severity);
} else {
print_notice("Please set Config::get(Config::LOG_DESTINATION) to 'sql' in config.php to enable database logging.");
}
?>
</div>
?>
</div>
<?php } ?>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">mail</i> <?= __('Mail configuration') ?>'>
<div dojoType="dijit.layout.ContentPane">

@ -117,7 +117,12 @@ class Pref_Users extends Handler_Administrative {
$user->login = mb_strtolower($login);
$user->access_level = (int) clean($_REQUEST["access_level"]);
$user->email = clean($_REQUEST["email"]);
$user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"]);
$user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? "");
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143 && !$user->otp_enabled) {
$user->otp_secret = null;
}
$user->save();
}
@ -243,13 +248,13 @@ class Pref_Users extends Handler_Administrative {
<table width='100%' class='users-list' id='users-list'>
<tr class='title'>
<td align='center' width='5%'> </td>
<td width='20%'><a href='#' onclick="Users.reload('login')"><?= ('Login') ?></a></td>
<td width='20%'><a href='#' onclick="Users.reload('access_level')"><?= ('Access Level') ?></a></td>
<td width='10%'><a href='#' onclick="Users.reload('num_feeds')"><?= ('Subscribed feeds') ?></a></td>
<td width='20%'><a href='#' onclick="Users.reload('created')"><?= ('Registered') ?></a></td>
<td width='20%'><a href='#' onclick="Users.reload('last_login')"><?= ('Last login') ?></a></td>
<tr>
<th></th>
<th><a href='#' onclick="Users.reload('login')"><?= ('Login') ?></a></th>
<th><a href='#' onclick="Users.reload('access_level')"><?= ('Access Level') ?></a></th>
<th><a href='#' onclick="Users.reload('num_feeds')"><?= ('Subscribed feeds') ?></a></th>
<th><a href='#' onclick="Users.reload('created')"><?= ('Registered') ?></a></th>
<th><a href='#' onclick="Users.reload('last_login')"><?= ('Last login') ?></a></th>
</tr>
<?php
@ -265,16 +270,19 @@ class Pref_Users extends Handler_Administrative {
foreach ($users as $user) { ?>
<tr data-row-id='<?= $user["id"] ?>' onclick='Users.edit(<?= $user["id"] ?>)' title="<?= __('Click to edit') ?>">
<td align='center'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this); event.stopPropagation();'
dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td><i class='material-icons'>person</i> <?= htmlspecialchars($user["login"]) ?></td>
<td width='30%'>
<i class='material-icons'>person</i>
<strong><?= htmlspecialchars($user["login"]) ?></strong>
</td>
<td><?= $access_level_names[$user["access_level"]] ?></td>
<td><?= $user["num_feeds"] ?></td>
<td><?= TimeHelper::make_local_datetime($user["created"], false) ?></td>
<td><?= TimeHelper::make_local_datetime($user["last_login"], false) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["created"], false) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["last_login"], false) ?></td>
</tr>
<?php } ?>
</table>

@ -60,6 +60,7 @@ class Prefs {
const DEBUG_HEADLINE_IDS = "DEBUG_HEADLINE_IDS";
const DISABLE_CONDITIONAL_COUNTERS = "DISABLE_CONDITIONAL_COUNTERS";
const WIDESCREEN_MODE = "WIDESCREEN_MODE";
const CDM_ENABLE_GRID = "CDM_ENABLE_GRID";
private const _DEFAULTS = [
Prefs::PURGE_OLD_DAYS => [ 60, Config::T_INT ],
@ -120,6 +121,7 @@ class Prefs {
Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ],
Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ],
Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ],
Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ],
];
const _PROFILE_BLACKLIST = [

@ -181,7 +181,7 @@ class RPC extends Handler_Protected {
$client_scheme = parse_url($client_location, PHP_URL_SCHEME);
$server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME);
if (Db_Updater::is_update_required()) {
if (Config::is_migration_needed()) {
$error = Errors::E_SCHEMA_MISMATCH;
} else if ($client_scheme != $server_scheme) {
$error = Errors::E_URL_SCHEME_MISMATCH;
@ -382,10 +382,10 @@ class RPC extends Handler_Protected {
}
function log() {
$msg = clean($_REQUEST['msg']);
$file = basename(clean($_REQUEST['file']));
$line = (int) clean($_REQUEST['line']);
$context = clean($_REQUEST['context']);
$msg = clean($_REQUEST['msg'] ?? "");
$file = basename(clean($_REQUEST['file'] ?? ""));
$line = (int) clean($_REQUEST['line'] ?? 0);
$context = clean($_REQUEST['context'] ?? "");
if ($msg) {
Logger::log_error(E_USER_WARNING,
@ -431,7 +431,7 @@ class RPC extends Handler_Protected {
Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD,
Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP,
Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL,
Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS] as $param) {
Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) {
$params[strtolower($param)] = (int) get_pref($param);
}
@ -443,7 +443,7 @@ class RPC extends Handler_Protected {
$params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE);
$params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT);
$params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY);
$params["bw_limit"] = (int) $_SESSION["bw_limit"];
$params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false);
$params["is_default_pw"] = UserHelper::is_default_password();
$params["label_base_index"] = LABEL_BASE_INDEX;
@ -472,6 +472,9 @@ class RPC extends Handler_Protected {
$params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE);
$params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE);
$params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif");
$params["icon_oval"] = $this->image_to_base64("images/oval.svg");
$params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg");
$params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif");
$params["labels"] = Labels::get_all($_SESSION["uid"]);
return $params;
@ -481,6 +484,8 @@ class RPC extends Handler_Protected {
if (file_exists($filename)) {
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if ($ext == "svg") $ext = "svg+xml";
return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename));
} else {
return "";
@ -517,6 +522,7 @@ class RPC extends Handler_Protected {
WHERE
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
$log_interval AND
errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND
errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
$sth->execute();
@ -559,7 +565,9 @@ class RPC extends Handler_Protected {
$hotkeys = array(
__("Navigation") => array(
"next_feed" => __("Open next feed"),
"next_unread_feed" => __("Open next unread feed"),
"prev_feed" => __("Open previous feed"),
"prev_unread_feed" => __("Open previous unread feed"),
"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
"next_headlines_page" => __("Scroll headlines by one page down"),
@ -603,6 +611,7 @@ class RPC extends Handler_Protected {
"feed_catchup" => __("Mark as read"),
"feed_reverse" => __("Reverse headlines"),
"feed_toggle_vgroup" => __("Toggle headline grouping"),
"feed_toggle_grid" => __("Toggle grid view"),
"feed_debug_update" => __("Debug feed update"),
"feed_debug_viewfeed" => __("Debug viewfeed()"),
"catchup_all" => __("Mark all feeds as read"),
@ -637,7 +646,9 @@ class RPC extends Handler_Protected {
static function get_hotkeys_map() {
$hotkeys = array(
"k" => "next_feed",
"K" => "next_unread_feed",
"j" => "prev_feed",
"J" => "prev_unread_feed",
"n" => "next_article_noscroll",
"p" => "prev_article_noscroll",
"N" => "article_page_down",
@ -663,6 +674,7 @@ class RPC extends Handler_Protected {
"a e" => "toggle_full_text",
"e" => "email_article",
"a q" => "close_article",
"a s" => "article_span_grid",
"a a" => "select_all",
"a u" => "select_unread",
"a U" => "select_marked",
@ -676,8 +688,9 @@ class RPC extends Handler_Protected {
"f q" => "feed_catchup",
"f x" => "feed_reverse",
"f g" => "feed_toggle_vgroup",
"f G" => "feed_toggle_grid",
"f D" => "feed_debug_update",
"f G" => "feed_debug_viewfeed",
"f %" => "feed_debug_viewfeed",
"f C" => "toggle_combined_mode",
"f c" => "toggle_cdm_expanded",
"Q" => "catchup_all",

File diff suppressed because it is too large Load Diff

@ -68,13 +68,13 @@ class Sanitizer {
// $rewrite_base_url = $site_url ? $site_url : Config::get_self_url();
$rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/";
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src])');
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
foreach ($entries as $entry) {
if ($entry->hasAttribute('href')) {
$entry->setAttribute('href',
rewrite_relative_url($rewrite_base_url, $entry->getAttribute('href')));
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href"));
$entry->setAttribute('rel', 'noopener noreferrer');
$entry->setAttribute("target", "_blank");
@ -82,7 +82,7 @@ class Sanitizer {
if ($entry->hasAttribute('src')) {
$entry->setAttribute('src',
rewrite_relative_url($rewrite_base_url, $entry->getAttribute('src')));
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src"));
}
if ($entry->nodeName == 'img') {
@ -94,12 +94,17 @@ class Sanitizer {
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
for ($i = 0; $i < count($matches); $i++) {
$matches[$i]["url"] = rewrite_relative_url($rewrite_base_url, $matches[$i]["url"]);
$matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]);
}
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
}
if ($entry->hasAttribute('poster')) {
$entry->setAttribute('poster',
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('poster'), $entry->tagName, "poster"));
}
if ($entry->hasAttribute('src') &&
($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {

@ -1,5 +1,11 @@
<?php
class UrlHelper {
const EXTRA_HREF_SCHEMES = [
"magnet",
"mailto",
"tel"
];
static $fetch_last_error;
static $fetch_last_error_code;
static $fetch_last_error_content;
@ -20,34 +26,51 @@ class UrlHelper {
}
/**
* Converts a (possibly) relative URL to a absolute one.
* Converts a (possibly) relative URL to a absolute one, using provided base URL.
* Provides some exceptions for additional schemes like data: if called with owning element/attribute.
*
* @param string $url Base URL (i.e. from where the document is)
* @param string $base_url Base URL (i.e. from where the document is)
* @param string $rel_url Possibly relative URL in the document
* @param string $owner_element Owner element tag name (i.e. "a") (optional)
* @param string $owner_attribute Owner attribute (i.e. "href") (optional)
*
* @return string Absolute URL
*/
public static function rewrite_relative($url, $rel_url) {
public static function rewrite_relative($base_url, $rel_url, string $owner_element = "", string $owner_attribute = "") {
$rel_parts = parse_url($rel_url);
if (!empty($rel_parts['host']) && !empty($rel_parts['scheme'])) {
return self::validate($rel_url);
// protocol-relative URL (rare but they exist)
} else if (strpos($rel_url, "//") === 0) {
# protocol-relative URL (rare but they exist)
return self::validate("https:" . $rel_url);
} else if (strpos($rel_url, "magnet:") === 0) {
# allow magnet links
// allow some extra schemes for A href
} else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) &&
$owner_element == "a" &&
$owner_attribute == "href") {
return $rel_url;
// allow limited subset of inline base64-encoded images for IMG elements
} else if (($rel_parts["scheme"] ?? "") == "data" &&
preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) &&
$owner_element == "img" &&
$owner_attribute == "src") {
return $rel_url;
} else {
$parts = parse_url($url);
$base_parts = parse_url($base_url);
$rel_parts['host'] = $parts['host'];
$rel_parts['scheme'] = $parts['scheme'];
$rel_parts['host'] = $base_parts['host'];
$rel_parts['scheme'] = $base_parts['scheme'];
if (isset($rel_parts['path'])) {
if (strpos($rel_parts['path'], '/') !== 0)
$rel_parts['path'] = '/' . $rel_parts['path'];
// experimental: if relative url path is not absolute (i.e. starting with /) concatenate it using base url path
// (i'm not sure if it's a good idea)
if (strpos($rel_parts['path'], '/') !== 0) {
$rel_parts['path'] = with_trailing_slash($base_parts['path'] ?? "") . $rel_parts['path'];
}
$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
@ -258,8 +281,7 @@ class UrlHelper {
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_ANY);
curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent :
SELF_USER_AGENT);
curl_setopt($ch, CURLOPT_USERAGENT, $useragent ? $useragent : Config::get_user_agent());
curl_setopt($ch, CURLOPT_ENCODING, "");
if ($http_referrer)
@ -271,10 +293,15 @@ class UrlHelper {
// holy shit closures in php
// download & upload are *expected* sizes respectively, could be zero
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use( &$max_size) {
Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function($curl_handle, $download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) {
//Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
if ($downloaded > $max_size) {
Debug::log("curl: reached max size of $max_size bytes requesting $url, aborting.", Debug::LOG_VERBOSE);
return 1;
}
return ($downloaded > $max_size) ? 1 : 0; // if max size is set, abort when exceeding it
return 0;
});
}
@ -461,7 +488,7 @@ class UrlHelper {
if (self::$fetch_last_error_code != 200) {
$error = error_get_last();
if ($error['message'] != $old_error['message']) {
if (($error['message'] ?? '') != ($old_error['message'] ?? '')) {
self::$fetch_last_error .= "; " . $error["message"];
}
@ -482,4 +509,26 @@ class UrlHelper {
}
}
public static function url_to_youtube_vid($url) {
$url = str_replace("youtube.com", "youtube-nocookie.com", $url);
$regexps = [
"/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/watch?v=([\w-]+)/",
"/\/\/youtu.be\/([\w-]+)/",
];
foreach ($regexps as $re) {
$matches = [];
if (preg_match($re, $url, $matches)) {
return $matches[1];
}
}
return false;
}
}

@ -48,7 +48,6 @@ class UserHelper {
$_SESSION["access_level"] = $user->access_level;
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
$_SESSION["ip_address"] = UserHelper::get_user_ip();
$_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']);
$_SESSION["pwd_hash"] = $user->pwd_hash;
$user->last_login = Db::NOW();
@ -76,7 +75,7 @@ class UserHelper {
$_SESSION["auth_module"] = false;
if (!$_SESSION["csrf_token"])
if (empty($_SESSION["csrf_token"]))
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
$_SESSION["ip_address"] = UserHelper::get_user_ip();
@ -241,6 +240,12 @@ class UserHelper {
if ($user) {
$user->otp_enabled = false;
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143) {
$user->otp_secret = null;
}
$user->save();
return true;
@ -282,8 +287,32 @@ class UserHelper {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
if (!$user->otp_enabled || $show_if_enabled)
return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded(mb_substr(sha1($user->salt), 0, 12));
$salt_based_secret = mb_substr(sha1($user->salt), 0, 12);
if (Config::get_schema_version() >= 143) {
$secret = $user->otp_secret;
if (empty($secret)) {
/* migrate secret if OTP is already enabled, otherwise make a new one */
if ($user->otp_enabled) {
$user->otp_secret = $salt_based_secret;
} else {
$user->otp_secret = bin2hex(get_random_bytes(10));
}
$user->save();
$secret = $user->otp_secret;
}
} else {
$secret = $salt_based_secret;
}
if (!$user->otp_enabled || $show_if_enabled) {
return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret);
}
}
return null;

@ -4,5 +4,8 @@
"chillerlan/php-qrcode": "^3.3",
"mervick/material-design-icons": "^2.2",
"j4mie/idiorm": "^1.5"
},
"require-dev": {
"phpstan/phpstan": "^0.12.99"
}
}

69
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e7f23b092328c903b06c8ae31bf13781",
"content-hash": "76e40cf59f811ee42d14ac41159c570a",
"packages": [
{
"name": "beberlei/assert",
@ -592,7 +592,72 @@
"time": "2020-10-28T17:51:34+00:00"
}
],
"packages-dev": [],
"packages-dev": [
{
"name": "phpstan/phpstan",
"version": "0.12.99",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4d40f1d759942f523be267a1bab6884f46ca3f7",
"reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7",
"shasum": ""
},
"require": {
"php": "^7.1|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "0.12-dev"
}
},
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"support": {
"issues": "https://github.com/phpstan/phpstan/issues",
"source": "https://github.com/phpstan/phpstan/tree/0.12.99"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
},
{
"url": "https://www.patreon.com/phpstan",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan",
"type": "tidelift"
}
],
"time": "2021-09-12T20:09:55+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],

@ -12,8 +12,8 @@ function swallowError(error) {
gulp.task('less', function(cb) {
gulp
.src(['themes/compact.less', 'themes/compact_night.less',
'themes/light.less', 'themes/night_blue.less', 'themes/night.less'])
.pipe(less())
'themes/light.less', 'themes/light-high-contrast.less', 'themes/night_blue.less', 'themes/night.less'])
.pipe(less({javascriptEnabled: true}))
.on('error', swallowError)
.pipe(
gulp.dest(function(f) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

@ -0,0 +1,17 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg width="38" height="38" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#257aa7">
<g fill="none" fill-rule="evenodd">
<g stroke-width="8" transform="matrix(0.83009609,0,0,0.83009609,4.0582705,4.0582705)">
<circle stroke-opacity=".5" cx="18" cy="18" r="18"/>
<path d="M 36,18 C 36,8.06 27.94,0 18,0">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"/>
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 740 B

@ -0,0 +1,33 @@
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
<svg width="120" height="30" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#257aa7">
<circle cx="15" cy="15" r="15">
<animate attributeName="r" from="15" to="15"
begin="0s" dur="0.8s"
values="15;9;15" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="1" to="1"
begin="0s" dur="0.8s"
values="1;.5;1" calcMode="linear"
repeatCount="indefinite" />
</circle>
<circle cx="60" cy="15" r="9" fill-opacity="0.3">
<animate attributeName="r" from="9" to="9"
begin="0s" dur="0.8s"
values="9;15;9" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="0.5" to="0.5"
begin="0s" dur="0.8s"
values=".5;1;.5" calcMode="linear"
repeatCount="indefinite" />
</circle>
<circle cx="105" cy="15" r="15">
<animate attributeName="r" from="15" to="15"
begin="0s" dur="0.8s"
values="15;9;15" calcMode="linear"
repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="1" to="1"
begin="0s" dur="0.8s"
values="1;.5;1" calcMode="linear"
repeatCount="indefinite" />
</circle>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -2,7 +2,7 @@
namespace Controls;
function attributes_to_string(array $attributes) {
$rv = "";
$rv = [];
foreach ($attributes as $k => $v) {
@ -10,10 +10,10 @@
if ($k === "disabled" && !sql_bool_to_bool($v))
continue;
$rv .= "$k=\"" . htmlspecialchars($v) . "\"";
array_push($rv, "$k=\"" . htmlspecialchars($v) . "\"");
}
return $rv;
return implode(" ", $rv);
}
// shortcut syntax (disabled)

@ -1,27 +1,32 @@
<?php
function stylesheet_tag($filename, $id = false) {
$timestamp = filemtime($filename);
function stylesheet_tag($filename, $attributes = []) {
$id_part = $id ? "id=\"$id\"" : "";
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"href" => "$filename?" . filemtime($filename),
"rel" => "stylesheet",
"type" => "text/css",
"data-orig-href" => $filename
],
$attributes));
return "<link rel=\"stylesheet\" $id_part type=\"text/css\" data-orig-href=\"$filename\" href=\"$filename?$timestamp\"/>\n";
return "<link $attributes_str/>\n";
}
function javascript_tag($filename) {
$query = "";
function javascript_tag($filename, $attributes = []) {
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"src" => "$filename?" . filemtime($filename),
"type" => "text/javascript",
"charset" => "utf-8"
],
$attributes));
if (!(strpos($filename, "?") === false)) {
$query = substr($filename, strpos($filename, "?")+1);
$filename = substr($filename, 0, strpos($filename, "?"));
}
$timestamp = filemtime($filename);
if ($query) $timestamp .= "&$query";
return "<script type=\"text/javascript\" charset=\"utf-8\" src=\"$filename?$timestamp\"></script>\n";
return "<script $attributes_str></script>\n";
}
function format_warning($msg, $id = "") {
@ -47,268 +52,3 @@ function print_warning($msg) {
function print_error($msg) {
return print format_error($msg);
}
// the following is deprecated and will be eventually removed
/*function print_select($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
print "<select name=\"$name\" id=\"$id\" $attributes>";
foreach ($values as $v) {
if ($v == $default)
$sel = "selected=\"1\"";
else
$sel = "";
$v = trim($v);
print "<option value=\"$v\" $sel>$v</option>";
}
print "</select>";
}
function print_select_hash($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
print "<select name=\"$name\" id='$id' $attributes>";
foreach (array_keys($values) as $v) {
if ($v == $default)
$sel = 'selected="selected"';
else
$sel = "";
$v = trim($v);
print "<option $sel value=\"$v\">".$values[$v]."</option>";
}
print "</select>";
}
function format_hidden($name, $value) {
return "<input dojoType=\"dijit.form.TextBox\" style=\"display : none\" name=\"$name\" value=\"$value\">";
}
function print_hidden($name, $value) {
print format_hidden($name, $value);
}
function format_checkbox($id, $checked, $value = "", $attributes = "") {
$checked_str = $checked ? "checked" : "";
$value_str = $value ? "value=\"$value\"" : "";
return "<input dojoType=\"dijit.form.CheckBox\" id=\"$id\" $value_str $checked_str $attributes name=\"$id\">";
}
function print_checkbox($id, $checked, $value = "", $attributes = "") {
print format_checkbox($id, $checked, $value, $attributes);
}
function format_button($type, $value, $attributes = "") {
return "<button dojoType=\"dijit.form.Button\" $attributes type=\"$type\">$value</button>";
}
function print_button($type, $value, $attributes = "") {
print format_button($type, $value, $attributes);
}
function print_feed_multi_select($id, $default_ids = [],
$attributes = "", $include_all_feeds = true,
$root_id = null, $nest_level = 0) {
$pdo = Db::pdo();
print_r(in_array("CAT:6",$default_ids));
if (!$root_id) {
print "<select multiple=\true\" id=\"$id\" name=\"$id\" $attributes>";
if ($include_all_feeds) {
$is_selected = (in_array("0", $default_ids)) ? "selected=\"1\"" : "";
print "<option $is_selected value=\"0\">".__('All feeds')."</option>";
}
}
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if (!$root_id) $root_id = null;
$sth = $pdo->prepare("SELECT id,title,
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_children
FROM ttrss_feed_categories
WHERE owner_uid = :uid AND
(parent_cat = :root_id OR (:root_id IS NULL AND parent_cat IS NULL)) ORDER BY title");
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
while ($line = $sth->fetch()) {
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
$is_selected = in_array("CAT:".$line["id"], $default_ids) ? "selected=\"1\"" : "";
printf("<option $is_selected value='CAT:%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
print_feed_multi_select($id, $default_ids, $attributes,
$include_all_feeds, $line["id"], $nest_level+1);
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE cat_id = ? AND owner_uid = ? ORDER BY title");
$f_sth->execute([$line['id'], $_SESSION['uid']]);
while ($fline = $f_sth->fetch()) {
$is_selected = (in_array($fline["id"], $default_ids)) ? "selected=\"1\"" : "";
$fline["title"] = "" . $fline["title"];
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = "" . $fline["title"];
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
if (!$root_id) {
$is_selected = in_array("CAT:0", $default_ids) ? "selected=\"1\"" : "";
printf("<option $is_selected value='CAT:0'>%s</option>",
__("Uncategorized"));
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE cat_id IS NULL AND owner_uid = ? ORDER BY title");
$f_sth->execute([$_SESSION['uid']]);
while ($fline = $f_sth->fetch()) {
$is_selected = in_array($fline["id"], $default_ids) ? "selected=\"1\"" : "";
$fline["title"] = "" . $fline["title"];
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = "" . $fline["title"];
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
} else {
$sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$is_selected = (in_array($line["id"], $default_ids)) ? "selected=\"1\"" : "";
printf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
}
}
if (!$root_id) {
print "</select>";
}
}
function print_feed_cat_select($id, $default_id, $attributes, $include_all_cats = true,
$root_id = null, $nest_level = 0) {
print format_feed_cat_select($id, $default_id, $attributes, $include_all_cats, $root_id, $nest_level);
}
function format_feed_cat_select($id, $default_id, $attributes, $include_all_cats = true,
$root_id = null, $nest_level = 0) {
$ret = "";
if (!$root_id) {
$ret .= "<select id=\"$id\" name=\"$id\" default=\"$default_id\" $attributes>";
}
$pdo = Db::pdo();
if (!$root_id) $root_id = null;
$sth = $pdo->prepare("SELECT id,title,
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_children
FROM ttrss_feed_categories
WHERE owner_uid = :uid AND
(parent_cat = :root_id OR (:root_id IS NULL AND parent_cat IS NULL)) ORDER BY title");
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
$found = 0;
while ($line = $sth->fetch()) {
++$found;
if ($line["id"] == $default_id) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
if ($line["title"])
$ret .= sprintf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
$ret .= format_feed_cat_select($id, $default_id, $attributes,
$include_all_cats, $line["id"], $nest_level+1);
}
if (!$root_id) {
if ($include_all_cats) {
if ($found > 0) {
$ret .= "<option disabled=\"1\">―――――――――――――――</option>";
}
if ($default_id == 0) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
$ret .= "<option $is_selected value=\"0\">".__('Uncategorized')."</option>";
}
$ret .= "</select>";
}
return $ret;
}
function print_label_select($name, $value, $attributes = "") {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$_SESSION['uid']]);
print "<select default=\"$value\" name=\"" . htmlspecialchars($name) .
"\" $attributes>";
while ($line = $sth->fetch()) {
$issel = ($line["caption"] == $value) ? "selected=\"1\"" : "";
print "<option value=\"".htmlspecialchars($line["caption"])."\"
$issel>" . htmlspecialchars($line["caption"]) . "</option>";
}
# print "<option value=\"ADD_LABEL\">" .__("Add label...") . "</option>";
print "</select>";
}
*/

@ -8,7 +8,7 @@ function format_backtrace($trace) {
if (isset($e["file"]) && isset($e["line"])) {
$fmt_args = [];
if (is_array($e["args"])) {
if (is_array($e["args"] ?? false)) {
foreach ($e["args"] as $a) {
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
@ -55,6 +55,8 @@ function ttrss_error_handler($errno, $errstr, $file, $line) {
if (class_exists("Logger"))
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
else
return false;
}
function ttrss_fatal_handler() {

@ -2,8 +2,8 @@
define('LABEL_BASE_INDEX', -1024);
define('PLUGIN_FEED_BASE_INDEX', -128);
/** constant is @deprecated, use Db_Updater::SCHEMA_VERSION instead */
define('SCHEMA_VERSION', Db_Updater::SCHEMA_VERSION);
/** constant is @deprecated, use Config::SCHEMA_VERSION instead */
define('SCHEMA_VERSION', Config::SCHEMA_VERSION);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
@ -157,22 +157,21 @@
require_once 'controls.php';
require_once 'controls_compat.php';
define('SELF_USER_AGENT', 'Tiny Tiny RSS/' . Config::get_version() . ' (http://tt-rss.org/)');
ini_set('user_agent', SELF_USER_AGENT);
ini_set('user_agent', Config::get_user_agent());
/* compat shims */
/** function is @deprecated */
/** function is @deprecated by Config::get_version() */
function get_version() {
return Config::get_version();
}
/** function is @deprecated */
/** function is @deprecated by Config::get_schema_version() */
function get_schema_version() {
return Config::get_schema_version();
}
/** function is @deprecated */
/** function is @deprecated by Debug::log() */
function _debug($msg) {
Debug::log($msg);
}
@ -182,37 +181,37 @@
return Feeds::_get_counters($feed, $is_cat, true, $_SESSION["uid"]);
}
/** function is @deprecated */
/** function is @deprecated by Sanitizer::sanitize() */
function sanitize($str, $force_remove_images = false, $owner = false, $site_url = false, $highlight_words = false, $article_id = false) {
return Sanitizer::sanitize($str, $force_remove_images, $owner, $site_url, $highlight_words, $article_id);
}
/** function is @deprecated */
/** function is @deprecated by UrlHelper::fetch() */
function fetch_file_contents($params) {
return UrlHelper::fetch($params);
}
/** function is @deprecated */
function rewrite_relative_url($url, $rel_url) {
return UrlHelper::rewrite_relative($url, $rel_url);
/** function is @deprecated by UrlHelper::rewrite_relative() */
function rewrite_relative_url($base_url, $rel_url) {
return UrlHelper::rewrite_relative($base_url, $rel_url);
}
/** function is @deprecated */
/** function is @deprecated by UrlHelper::validate() */
function validate_url($url) {
return UrlHelper::validate($url);
}
/** function is @deprecated */
/** function is @deprecated by UserHelper::authenticate() */
function authenticate_user($login, $password, $check_only = false, $service = false) {
return UserHelper::authenticate($login, $password, $check_only, $service);
}
/** function is @deprecated */
/** function is @deprecated by TimeHelper::smart_date_time() */
function smart_date_time($timestamp, $tz_offset = 0, $owner_uid = false, $eta_min = false) {
return TimeHelper::smart_date_time($timestamp, $tz_offset, $owner_uid, $eta_min);
}
/** function is @deprecated */
/** function is @deprecated by TimeHelper::make_local_datetime() */
function make_local_datetime($timestamp, $long, $owner_uid = false, $no_smart_dt = false, $eta_min = false) {
return TimeHelper::make_local_datetime($timestamp, $long, $owner_uid, $no_smart_dt, $eta_min);
}
@ -236,6 +235,14 @@
}
}
function with_trailing_slash(string $str) : string {
if (substr($str, -1) === "/") {
return $str;
} else {
return "$str/";
}
}
function make_password($length = 12) {
$password = "";
$possible = "0123456789abcdfghjkmnpqrstvwxyzABCDFGHJKMNPQRSTVWXYZ*%+^";
@ -412,6 +419,8 @@
$check = "themes.local/$theme";
if (file_exists($check)) return $check;
return "";
}
function theme_exists($theme) {

@ -15,9 +15,7 @@
} ?>
<?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) {
echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET)));
} ?>
<?= Config::get_override_links() ?>
<style type="text/css">
@media (prefers-color-scheme: dark) {
@ -85,7 +83,7 @@
</script>
<?php $return = urlencode(Config::make_self_url()) ?>
<?php $return = urlencode(!empty($_REQUEST['return']) ? $_REQUEST['return'] : with_trailing_slash(Config::make_self_url())) ?>
<div class="container">

@ -19,37 +19,31 @@
ini_set("session.gc_maxlifetime", $session_expire);
ini_set("session.cookie_lifetime", "0");
// prolong PHP session cookie
if (isset($_COOKIE[$session_name]))
setcookie($session_name,
$_COOKIE[$session_name],
time() + $session_expire,
ini_get("session.cookie_path"),
ini_get("session.cookie_domain"),
ini_get("session.cookie_secure"),
ini_get("session.cookie_httponly"));
function validate_session() {
if (\Config::get(\Config::SINGLE_USER_MODE)) return true;
if (isset($_SESSION["ref_schema_version"]) && $_SESSION["ref_schema_version"] != \Config::get_schema_version()) {
$_SESSION["login_error_msg"] =
__("Session failed to validate (schema version changed)");
return false;
}
$pdo = \Db::pdo();
$pdo = \Db::pdo();
if (!empty($_SESSION["uid"])) {
if ($_SESSION["user_agent"] != sha1($_SERVER['HTTP_USER_AGENT'])) {
$_SESSION["login_error_msg"] = __("Session failed to validate (UA changed).");
return false;
}
$user = \ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
if ($user) {
if ($user->pwd_hash != $_SESSION["pwd_hash"]) {
$_SESSION["login_error_msg"] =
__("Session failed to validate (password changed)");
$_SESSION["login_error_msg"] = __("Session failed to validate (password changed)");
return false;
}
} else {
$_SESSION["login_error_msg"] =
__("Session failed to validate (user not found)");
$_SESSION["login_error_msg"] = __("Session failed to validate (user not found)");
return false;
}
}
@ -121,17 +115,17 @@
return true;
}
if (!\Config::get(\Config::SINGLE_USER_MODE)) {
if (\Config::get_schema_version() >= 0) {
session_set_save_handler('\Sessions\ttrss_open',
'\Sessions\ttrss_close', '\Sessions\ttrss_read',
'\Sessions\ttrss_write', '\Sessions\ttrss_destroy',
'\Sessions\ttrss_gc');
register_shutdown_function('session_write_close');
}
if (!defined('NO_SESSION_AUTOSTART')) {
if (isset($_COOKIE[session_name()])) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
if (!defined('NO_SESSION_AUTOSTART')) {
if (isset($_COOKIE[session_name()])) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
}
}
}

@ -31,13 +31,11 @@
<?php if ($_SESSION["uid"] && empty($_SESSION["safe_mode"])) {
$theme = get_pref(Prefs::USER_CSS_THEME);
if ($theme && theme_exists("$theme")) {
echo stylesheet_tag(get_theme_path($theme), 'theme_css');
echo stylesheet_tag(get_theme_path($theme), ['id' => 'theme_css']);
}
} ?>
<?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) {
echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET)));
} ?>
<?= Config::get_override_links() ?>
<script type="text/javascript">
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
@ -112,19 +110,31 @@
}
</style>
<noscript>
<?= stylesheet_tag("themes/light.css") ?>
<style type="text/css">
body.css_loading noscript {
display : block;
margin : 16px;
}
</style>
</noscript>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="referrer" content="no-referrer"/>
</head>
<body class="flat ttrss_main ttrss_index css_loading">
<div id="overlay" style="display : block">
<noscript class="alert alert-error"><?= ('Javascript is disabled. Please enable it.') ?></noscript>
<div id="overlay">
<div id="overlay_inner">
<?= __("Loading, please wait...") ?>
<div dojoType="dijit.ProgressBar" places="0" style="width : 300px" id="loading_bar"
progress="0" maximum="100">
</div>
<noscript><br/><?php print_error('Javascript is disabled. Please enable it.') ?></noscript>
</div>
</div>
@ -133,9 +143,10 @@
<div id="main" dojoType="dijit.layout.BorderContainer">
<div id="feeds-holder" dojoType="dijit.layout.ContentPane" region="leading" style="width : 20%" splitter="true">
<div id="feedlistLoading">
<img src='images/indicator_tiny.gif'/>
<?= __("Loading, please wait..."); ?></div>
<div id="feedlistLoading" class="text-center text-muted text-small">
<img class="icon-three-dots" src="images/three-dots.svg?2">
<?= __("Loading, please wait..."); ?>
</div>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_FEED_TREE, function ($result) {
echo $result;
@ -148,75 +159,81 @@
<div id="toolbar-frame" dojoType="dijit.layout.ContentPane" region="top">
<div id="toolbar" dojoType="fox.Toolbar">
<i class="material-icons net-alert" style="display : none"
title="<?= __("Communication problem with server.") ?>">error_outline</i>
<i class="material-icons log-alert" style="display : none" onclick="App.openPreferences('system')"
title="<?= __("Recent entries found in event log.") ?>">warning</i>
<i id="updates-available" class="material-icons icon-new-version" style="display : none"
title="<?= __('Updates are available from Git.') ?>">new_releases</i>
<?php
<!-- order 0, default -->
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_MAIN_TOOLBAR_BUTTON, function ($result) {
echo $result;
});
?>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_MAIN_TOOLBAR_BUTTON, function ($result) {
echo $result;
});
?>
<div id="toolbar-headlines" dojoType="fox.Toolbar" style="order : 10">
<!-- order 5: alert icons -->
</div>
<i class="material-icons net-alert" style="display : none; order : 5"
title="<?= __("Communication problem with server.") ?>">error_outline</i>
<form id="toolbar-main" action="" style="order : 20" onsubmit='return false'>
<select name="view_mode" title="<?= __('Show articles') ?>"
onchange="App.onViewModeChanged()"
dojoType="fox.form.Select">
<option selected="selected" value="adaptive"><?= __('Adaptive') ?></option>
<option value="all_articles"><?= __('All Articles') ?></option>
<option value="marked"><?= __('Starred') ?></option>
<option value="published"><?= __('Published') ?></option>
<option value="unread"><?= __('Unread') ?></option>
<option value="has_note"><?= __('With Note') ?></option>
<!-- <option value="noscores"><?= __('Ignore Scoring') ?></option> -->
</select>
<select title="<?= __('Sort articles') ?>"
onchange="App.onViewModeChanged()"
dojoType="fox.form.Select" name="order_by">
<option selected="selected" value="default"><?= __('Default') ?></option>
<option value="feed_dates"><?= __('Newest first') ?></option>
<option value="date_reverse"><?= __('Oldest first') ?></option>
<option value="title"><?= __('Title') ?></option>
<i class="material-icons log-alert" style="display : none; order : 5" onclick="App.openPreferences('system')"
title="<?= __("Recent entries found in event log.") ?>">warning</i>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) {
foreach ($result as $sort_value => $sort_title) {
print "<option value=\"" . htmlspecialchars($sort_value) . "\">$sort_title</option>";
}
});
?>
</select>
<i id="updates-available" class="material-icons icon-new-version" style="display : none; order: 5"
title="<?= __('Updates are available from Git.') ?>">new_releases</i>
<div dojoType="fox.form.ComboButton" onclick="Feeds.catchupCurrent()">
<span><?= __('Mark as read') ?></span>
<div dojoType="dijit.DropDownMenu">
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1day')">
<?= __('Older than one day') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1week')">
<?= __('Older than one week') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('2week')">
<?= __('Older than two weeks') ?>
</div>
</div>
</div>
<!-- order 10: headlines toolbar -->
<div id="toolbar-headlines" dojoType="fox.Toolbar" style="order : 10"> </div>
<!-- order 20: main toolbar contents (dropdowns) -->
<form id="toolbar-main" dojoType="dijit.form.Form" action="" style="order : 20" onsubmit="return false">
<select name="view_mode" title="<?= __('Show articles') ?>"
onchange="Feeds.onViewModeChanged()"
dojoType="fox.form.Select">
<option selected="selected" value="adaptive"><?= __('Adaptive') ?></option>
<option value="all_articles"><?= __('All Articles') ?></option>
<option value="marked"><?= __('Starred') ?></option>
<option value="published"><?= __('Published') ?></option>
<option value="unread"><?= __('Unread') ?></option>
<option value="has_note"><?= __('With Note') ?></option>
</select>
<select title="<?= __('Sort articles') ?>"
onchange="Feeds.onViewModeChanged()"
dojoType="fox.form.Select" name="order_by">
<option selected="selected" value="default"><?= __('Default') ?></option>
<option value="feed_dates"><?= __('Newest first') ?></option>
<option value="date_reverse"><?= __('Oldest first') ?></option>
<option value="title"><?= __('Title') ?></option>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) {
foreach ($result as $sort_value => $sort_title) {
print "<option value=\"" . htmlspecialchars($sort_value) . "\">$sort_title</option>";
}
});
?>
</select>
<div class="catchup-button" dojoType="fox.form.ComboButton" onclick="Feeds.catchupCurrent()">
<span><?= __('Mark as read') ?></span>
<div dojoType="dijit.DropDownMenu">
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1day')">
<?= __('Older than one day') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1week')">
<?= __('Older than one week') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('2week')">
<?= __('Older than two weeks') ?>
</div>
</div>
</div>
</form>
<!-- toolbar actions dropdown: order 30 -->
<div class="action-chooser" style="order : 30">
<?php
@ -225,7 +242,7 @@
});
?>
<div dojoType="fox.form.DropDownButton" class="action-button" title="<?= __('Actions...') ?>">
<div dojoType="fox.form.DropDownButton" class="action-button" title="<?= __('Actions...') ?>">
<span><i class="material-icons">menu</i></span>
<div dojoType="dijit.Menu" style="display: none">
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcPrefs')"><?= __('Preferences...') ?></div>

@ -18,6 +18,15 @@ const App = {
is_prefs: false,
LABEL_BASE_INDEX: -1024,
_translations: {},
Hash: {
get: function() {
return dojo.queryToObject(window.location.hash.substring(1));
},
set: function(params) {
const obj = dojo.queryToObject(window.location.hash.substring(1));
window.location.hash = dojo.objectToQuery({...obj, ...params});
}
},
l10n: {
ngettext: function(msg1, msg2, n) {
return self.__((parseInt(n) > 1) ? msg2 : msg1);
@ -505,9 +514,12 @@ const App = {
this.LABEL_BASE_INDEX = parseInt(params[k]);
break;
case "cdm_auto_catchup":
if (params[k] == 1) {
const hl = App.byId("headlines-frame");
if (hl) hl.addClassName("auto_catchup");
{
const headlines = App.byId("headlines-frame");
// we could be in preferences
if (headlines)
headlines.setAttribute("data-auto-catchup", params[k] ? "true" : "false");
}
break;
case "hotkeys":
@ -676,15 +688,16 @@ const App = {
checkBrowserFeatures: function() {
let errorMsg = "";
['MutationObserver'].forEach(function(wf) {
if (!(wf in window)) {
errorMsg = `Browser feature check failed: <code>window.${wf}</code> not found.`;
['MutationObserver', 'requestIdleCallback'].forEach((t) => {
if (!(t in window)) {
errorMsg = `Browser check failed: <code>window.${t}</code> not found.`;
throw new Error(errorMsg);
}
});
if (errorMsg) {
this.Error.fatal(errorMsg, {info: navigator.userAgent});
if (typeof Promise.allSettled == "undefined") {
errorMsg = `Browser check failed: <code>Promise.allSettled</code> is not defined.`;
throw new Error(errorMsg);
}
return errorMsg == "";
@ -768,13 +781,10 @@ const App = {
}
});
const toolbar = document.forms["toolbar-main"];
dijit.getEnclosingWidget(toolbar.view_mode).attr('value',
this.getInitParam("default_view_mode"));
dijit.getEnclosingWidget(toolbar.order_by).attr('value',
this.getInitParam("default_view_order_by"));
dijit.byId('toolbar-main').setValues({
view_mode: this.getInitParam("default_view_mode"),
order_by: this.getInitParam("default_view_order_by")
});
this.setLoadingProgress(50);
@ -841,13 +851,6 @@ const App = {
document.title = tmp;
},
onViewModeChanged: function() {
const view_mode = document.forms["toolbar-main"].view_mode.value;
App.findAll("body")[0].setAttribute("view-mode", view_mode);
return Feeds.reloadCurrent('');
},
hotkeyHandler: function(event) {
if (event.target.nodeName == "INPUT" || event.target.nodeName == "TEXTAREA") return;
@ -869,41 +872,44 @@ const App = {
},
setWidescreen: function(wide) {
const article_id = Article.getActive();
const headlines_frame = App.byId("headlines-frame");
const content_insert = dijit.byId("content-insert");
// TODO: setStyle stuff should probably be handled by CSS
if (wide) {
dijit.byId("headlines-wrap-inner").attr("design", 'sidebar');
dijit.byId("content-insert").attr("region", "trailing");
content_insert.attr("region", "trailing");
dijit.byId("content-insert").domNode.setStyle({width: '50%',
content_insert.domNode.setStyle({width: '50%',
height: 'auto',
borderTopWidth: '0px' });
if (parseInt(Cookie.get("ttrss_ci_width")) > 0) {
dijit.byId("content-insert").domNode.setStyle(
content_insert.domNode.setStyle(
{width: Cookie.get("ttrss_ci_width") + "px" });
}
App.byId("headlines-frame").setStyle({ borderBottomWidth: '0px' });
App.byId("headlines-frame").addClassName("wide");
headlines_frame.setStyle({ borderBottomWidth: '0px' });
} else {
dijit.byId("content-insert").attr("region", "bottom");
content_insert.attr("region", "bottom");
dijit.byId("content-insert").domNode.setStyle({width: 'auto',
content_insert.domNode.setStyle({width: 'auto',
height: '50%',
borderTopWidth: '0px'});
if (parseInt(Cookie.get("ttrss_ci_height")) > 0) {
dijit.byId("content-insert").domNode.setStyle(
content_insert.domNode.setStyle(
{height: Cookie.get("ttrss_ci_height") + "px" });
}
App.byId("headlines-frame").setStyle({ borderBottomWidth: '1px' });
App.byId("headlines-frame").removeClassName("wide");
headlines_frame.setStyle({ borderBottomWidth: '1px' });
}
headlines_frame.setAttribute("data-is-wide-screen", wide ? "true" : "false");
Article.close();
if (article_id) Article.view(article_id);
@ -932,16 +938,32 @@ const App = {
} else {
this.hotkey_actions["next_feed"] = () => {
const rv = dijit.byId("feedTree").getNextFeed(
const [feed, is_cat] = Feeds.getNextFeed(
Feeds.getActive(), Feeds.activeIsCat());
if (rv) Feeds.open({feed: rv[0], is_cat: rv[1], delayed: true})
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["next_unread_feed"] = () => {
const [feed, is_cat] = Feeds.getNextFeed(
Feeds.getActive(), Feeds.activeIsCat(), true);
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["prev_feed"] = () => {
const rv = dijit.byId("feedTree").getPreviousFeed(
const [feed, is_cat] = Feeds.getPreviousFeed(
Feeds.getActive(), Feeds.activeIsCat());
if (rv) Feeds.open({feed: rv[0], is_cat: rv[1], delayed: true})
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["prev_unread_feed"] = () => {
const [feed, is_cat] = Feeds.getPreviousFeed(
Feeds.getActive(), Feeds.activeIsCat(), true);
if (feed !== false)
Feeds.open({feed: feed, is_cat: is_cat, delayed: true})
};
this.hotkey_actions["next_article_or_scroll"] = (event) => {
if (this.isCombinedMode())
@ -1103,6 +1125,12 @@ const App = {
this.hotkey_actions["feed_reverse"] = () => {
Headlines.reverse();
};
this.hotkey_actions["feed_toggle_grid"] = () => {
xhr.json("backend.php", {op: "rpc", method: "togglepref", key: "CDM_ENABLE_GRID"}, (reply) => {
App.setInitParam("cdm_enable_grid", reply.value);
Headlines.renderAgain();
})
};
this.hotkey_actions["feed_toggle_vgroup"] = () => {
xhr.post("backend.php", {op: "rpc", method: "togglepref", key: "VFEED_GROUP_BY_FEED"}, () => {
Feeds.reloadCurrent();
@ -1195,6 +1223,9 @@ const App = {
Headlines.renderAgain();
});
};
this.hotkey_actions["article_span_grid"] = () => {
Article.cdmToggleGridSpan(Article.getActive());
};
}
},
openPreferences: function(tab) {
@ -1269,6 +1300,6 @@ const App = {
default:
console.log("quickMenuGo: unknown action: " + opid);
}
}
},
}

@ -93,6 +93,16 @@ const Article = {
w.opener = null;
w.location = url;
},
cdmToggleGridSpan: function(id) {
const row = App.byId(`RROW-${id}`);
if (row) {
row.toggleClassName('grid-span-row');
this.setActive(id);
this.cdmMoveToId(id);
}
},
cdmUnsetActive: function (event) {
const row = App.byId(`RROW-${Article.getActive()}`);
@ -144,10 +154,15 @@ const Article = {
).join(", ") : `${__("no tags")}`}</span>`;
},
renderLabels: function(id, labels) {
return `<span class="labels" data-labels-for="${id}">${labels.map((label) => `
<span class="label" data-label-id="${label[0]}"
style="color : ${label[2]}; background-color : ${label[3]}">${App.escapeHtml(label[1])}</span>`
).join("")}</span>`;
return `<span class="labels" data-labels-for="${id}">
${labels.map((label) => `
<a href="#" class="label" data-label-id="${label[0]}"
style="color : ${label[2]}; background-color : ${label[3]}"
onclick="event.stopPropagation(); Feeds.open({feed:'${label[0]}'})">
${App.escapeHtml(label[1])}
</a>`
).join("")}
</span>`;
},
renderEnclosures: function (enclosures) {
return `
@ -240,12 +255,12 @@ const Article = {
return comments;
},
unpack: function(row) {
if (row.hasAttribute("data-content")) {
if (row.getAttribute("data-is-packed") == "1") {
console.log("unpacking: " + row.id);
const container = row.querySelector(".content-inner");
container.innerHTML = row.getAttribute("data-content").trim();
container.innerHTML = row.getAttribute("data-content").trim() + row.getAttribute("data-rendered-enclosures").trim();
dojo.parser.parse(container);
@ -257,18 +272,23 @@ const Article = {
if (App.isCombinedMode() && App.byId("main").hasClassName("expandable"))
row.setAttribute("data-content-original", row.getAttribute("data-content"));
row.removeAttribute("data-content");
row.setAttribute("data-is-packed", "0");
PluginHost.run(PluginHost.HOOK_ARTICLE_RENDERED_CDM, row);
}
},
pack: function(row) {
if (row.hasAttribute("data-content-original")) {
if (row.getAttribute("data-is-packed") != "1") {
console.log("packing", row.id);
row.setAttribute("data-content", row.getAttribute("data-content-original"));
row.removeAttribute("data-content-original");
row.setAttribute("data-is-packed", "1");
const content_inner = row.querySelector(".content-inner");
row.querySelector(".content-inner").innerHTML = "&nbsp;";
// missing in unexpanded mode
if (content_inner)
content_inner.innerHTML = `<div class="text-center text-muted">
${__("Loading, please wait...")}
</div>`
}
},
view: function (id, no_expand) {
@ -317,7 +337,7 @@ const Article = {
},
editTags: function (id) {
const dialog = new fox.SingleUseDialog({
title: __("Edit article Tags"),
title: __("Article tags"),
content: `
${App.FormFields.hidden_tag("id", id.toString())}
${App.FormFields.hidden_tag("op", "article")}
@ -329,7 +349,7 @@ const Article = {
<section>
<textarea dojoType='dijit.form.SimpleTextarea' rows='4' disabled='true'
id='tags_str' name='tags_str'></textarea>
id='tags_str' name='tags_str'>${__("Loading, please wait...")}</textarea>
<div class='autocomplete' id='tags_choices' style='display:none'></div>
</section>
@ -384,10 +404,12 @@ const Article = {
const ctr = App.byId("headlines-frame");
const row = App.byId(`RROW-${id}`);
if (!row || !ctr) return;
if (ctr && row) {
const grid_gap = parseInt(window.getComputedStyle(ctr).gridGap) || 0;
if (force_to_top || !App.Scrollable.fitsInContainer(row, ctr)) {
ctr.scrollTop = row.offsetTop;
if (force_to_top || !App.Scrollable.fitsInContainer(row, ctr)) {
ctr.scrollTop = row.offsetTop - grid_gap;
}
}
},
setActive: function (id) {
@ -396,7 +418,9 @@ const Article = {
App.findAll("div[id*=RROW][class*=active]").forEach((row) => {
row.removeClassName("active");
Article.pack(row);
if (App.isCombinedMode() && !App.getInitParam("cdm_expanded"))
Article.pack(row);
});
const row = App.byId(`RROW-${id}`);

@ -3,7 +3,7 @@
/* eslint-disable new-cap */
/* eslint-disable no-new */
/* global __, dojo, dijit, Notify, App, Feeds, xhrPost, xhr, Tables, fox */
/* global __, dojo, dijit, Notify, App, Feeds, xhr, Tables, fox */
/* exported CommonDialogs */
const CommonDialogs = {
@ -33,7 +33,7 @@ const CommonDialogs = {
<section>
<fieldset>
<div style='float : right'><img style='display : none' id='feed_add_spinner' src='images/indicator_white.gif'></div>
<div class='pull-right'><img style='display : none' id='feed_add_spinner' src='${App.getInitParam('icon_oval')}'></div>
<input style='font-size : 16px; width : 500px;'
placeHolder="${__("Feed or site URL")}"
dojoType='dijit.form.ValidationTextBox'
@ -181,7 +181,7 @@ const CommonDialogs = {
}
} catch (e) {
console.error(transport.responseText);
console.error(reply);
App.Error.report(e);
}
});
@ -248,7 +248,7 @@ const CommonDialogs = {
${reply.map((row) => `
<tr data-row-id='${row.id}'>
<td width='5%' align='center'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this)' dojoType="dijit.form.CheckBox"
type="checkbox">
</td>
@ -335,6 +335,10 @@ const CommonDialogs = {
id: "feedEditDlg",
title: __("Edit feed"),
feed_title: "",
E_ICON_FILE_TOO_LARGE: 'E_ICON_FILE_TOO_LARGE',
E_ICON_RENAME_FAILED: 'E_ICON_RENAME_FAILED',
E_ICON_UPLOAD_FAILED: 'E_ICON_UPLOAD_FAILED',
E_ICON_UPLOAD_SUCCESS: 'E_ICON_UPLOAD_SUCCESS',
unsubscribe: function() {
if (confirm(__("Unsubscribe from %s?").replace("%s", this.feed_title))) {
dialog.hide();
@ -361,20 +365,18 @@ const CommonDialogs = {
xhr.open( 'POST', 'backend.php', true );
xhr.onload = function () {
console.log(this.responseText);
const ret = JSON.parse(this.responseText);
// TODO: make a notice box within panel content
switch (parseInt(this.responseText)) {
case 1:
Notify.error("Upload failed: icon is too big.");
switch (ret.rc) {
case dialog.E_ICON_FILE_TOO_LARGE:
alert(__("Icon file is too large."));
break;
case 2:
Notify.error("Upload failed.");
case dialog.E_ICON_UPLOAD_FAILED:
alert(__("Upload failed."));
break;
default:
case dialog.E_ICON_UPLOAD_SUCCESS:
{
Notify.info("Upload complete.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
@ -383,12 +385,16 @@ const CommonDialogs = {
const icon = dialog.domNode.querySelector(".feedIcon");
if (icon) {
icon.src = this.responseText;
icon.src = ret.icon_url;
icon.show();
}
input.value = "";
}
break;
default:
alert(this.responseText);
break;
}
};
@ -400,9 +406,7 @@ const CommonDialogs = {
if (confirm(__("Remove stored feed icon?"))) {
Notify.progress("Removing feed icon...", true);
const query = {op: "pref-feeds", method: "removeicon", feed_id: id};
xhr.post("backend.php", query, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "removeicon", feed_id: id}, () => {
Notify.info("Feed icon removed.");
if (App.isPrefs())
@ -474,7 +478,7 @@ const CommonDialogs = {
<fieldset>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Feed title")}"
style='font-size : 16px; width: 500px' name='title' value="${App.escapeHtml(feed.title)}">
style='font-size : 16px; width: 530px' name='title' value="${App.escapeHtml(feed.title)}">
</fieldset>
<fieldset>
@ -565,19 +569,21 @@ const CommonDialogs = {
<div dojoType="dijit.layout.ContentPane" title="${__('Icon')}">
<div><img class='feedIcon' style="${feed.icon ? "" : "display : none"}" src="${feed.icon ? App.escapeHtml(feed.icon) : ""}"></div>
<label class="dijitButton">${__("Upload new icon...")}
<label class="dijitButton">
${App.FormFields.icon("file_upload")}
${__("Upload new icon...")}
<input style="display: none" type="file" onchange="App.dialogOf(this).uploadIcon(this)">
</label>
${App.FormFields.submit_tag(__("Remove"), {class: "alt-danger", onclick: "App.dialogOf(this).removeIcon("+feed_id+")"})}
${App.FormFields.submit_tag(App.FormFields.icon("delete") + " " + __("Remove"), {class: "alt-danger", onclick: "App.dialogOf(this).removeIcon("+feed_id+")"})}
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Plugins')}">
${reply.plugin_data}
</div>
</div>
<footer>
${App.FormFields.button_tag(__("Unsubscribe"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).unsubscribe()"})}
${App.FormFields.submit_tag(__("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.button_tag(App.FormFields.icon("delete") + " " + __("Unsubscribe"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).unsubscribe()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
@ -634,6 +640,7 @@ const CommonDialogs = {
onclick='window.open("https://tt-rss.org/wiki/GeneratedFeeds")'>
<i class='material-icons'>help</i> ${__("More info...")}</button>
<button dojoType='dijit.form.Button' onclick="return App.dialogOf(this).regenFeedKey('${feed}', '${is_cat}')">
${App.FormFields.icon("refresh")}
${__('Generate new URL')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>

@ -38,16 +38,19 @@ const Filters = {
console.log("got results:" + result.length);
App.byId("prefFilterProgressMsg").innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
const loading_message = test_dialog.domNode.querySelector(".loading-message");
const results_list = test_dialog.domNode.querySelector(".filter-results-list");
loading_message.innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
console.log(offset + " " + test_dialog.max_offset);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("table", { innerHTML: result[i]});
const tmp = dojo.create("div", { innerHTML: result[i]});
App.byId("prefFilterTestResultList").innerHTML += tmp.innerHTML;
results_list.innerHTML += tmp.innerHTML;
}
if (test_dialog.results < 30 && offset < test_dialog.max_offset) {
@ -60,14 +63,15 @@ const Filters = {
} else {
// all done
Element.hide("prefFilterLoadingIndicator");
test_dialog.domNode.querySelector(".loading-indicator").hide();
if (test_dialog.results == 0) {
App.byId("prefFilterTestResultList").innerHTML = `<tr><td align='center'>
${__('No recent articles matching this filter have been found.')}</td></tr>`;
App.byId("prefFilterProgressMsg").innerHTML = "Articles matching this filter:";
results_list.innerHTML = `<li class="text-center text-muted">
${__('No recent articles matching this filter have been found.')}</li>`;
loading_message.innerHTML = __("Articles matching this filter:");
} else {
App.byId("prefFilterProgressMsg").innerHTML = __("Found %d articles matching this filter:")
loading_message.innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
@ -75,7 +79,7 @@ const Filters = {
} else if (!result) {
console.log("getTestResults: can't parse results object");
Element.hide("prefFilterLoadingIndicator");
test_dialog.domNode.querySelector(".loading-indicator").hide();
Notify.error("Error while trying to get filter test results.");
} else {
console.log("getTestResults: dialog closed, bailing out.");
@ -86,12 +90,12 @@ const Filters = {
});
},
content: `
<div>
<img id='prefFilterLoadingIndicator' src='images/indicator_tiny.gif'>&nbsp;
<span id='prefFilterProgressMsg'>Looking for articles...</span>
<div class="text-muted">
<img class="loading-indicator icon-three-dots" src="${App.getInitParam("icon_three_dots")}">
<span class="loading-message">${__("Looking for articles...")}</span>
</div>
<ul class='panel panel-scrollable list list-unstyled' id='prefFilterTestResultList'></ul>
<ul class='panel panel-scrollable list list-unstyled filter-results-list'></ul>
<footer class='text-center'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>${__('Close this window')}</button>
@ -115,7 +119,7 @@ const Filters = {
const li = document.createElement('li');
li.addClassName("rule");
li.innerHTML = `${App.FormFields.checkbox_tag("", false, {onclick: 'Lists.onRowChecked(this)'})}
li.innerHTML = `${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class="name" onclick='App.dialogOf(this).onRuleClicked(this)'>${reply}</span>
<span class="payload" >${App.FormFields.hidden_tag("rule[]", rule)}</span>`;
@ -147,7 +151,7 @@ const Filters = {
const li = document.createElement('li');
li.addClassName("action");
li.innerHTML = `${App.FormFields.checkbox_tag("", false, {onclick: 'Lists.onRowChecked(this)'})}
li.innerHTML = `${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class="name" onclick='App.dialogOf(this).onActionClicked(this)'>${reply}</span>
<span class="payload">${App.FormFields.hidden_tag("action[]", action)}</span>`;
@ -229,7 +233,7 @@ const Filters = {
<footer>
${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("More info"), "", {class: 'pull-left alt-info',
onclick: "window.open('https://tt-rss.org/wiki/ContentFilters')"})}
${App.FormFields.submit_tag(__("Save rule"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
@ -313,7 +317,7 @@ const Filters = {
"filterDlg_actionParamPlugin")}
</section>
<footer>
${App.FormFields.submit_tag(__("Save action"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
@ -511,13 +515,13 @@ const Filters = {
<footer>
${filter_id ?
`
${App.FormFields.button_tag(__("Remove"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).removeFilter()"})}
${App.FormFields.button_tag(__("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(__("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.button_tag(App.FormFields.icon("delete") + " " + __("Remove"), "", {class: "pull-left alt-danger", onclick: "App.dialogOf(this).removeFilter()"})}
${App.FormFields.button_tag(App.FormFields.icon("check_circle") + " " + __("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
` : `
${App.FormFields.button_tag(__("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(__("Create"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.button_tag(App.FormFields.icon("check_circle") + " " + __("Test"), "", {class: "alt-info", onclick: "App.dialogOf(this).test()"})}
${App.FormFields.submit_tag(App.FormFields.icon("add") + " " + __("Create"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
`}
</footer>

@ -54,45 +54,6 @@ define(["dojo/_base/declare", "dijit/tree/ForestStoreModel"], function (declare)
if (treeItem)
return this.store.setValue(treeItem, key, value);
},
getNextUnreadFeed: function (feed, is_cat) {
if (!this.store._itemsByIdentity)
return null;
let treeItem;
if (is_cat) {
treeItem = this.store._itemsByIdentity['CAT:' + feed];
} else {
treeItem = this.store._itemsByIdentity['FEED:' + feed];
}
const items = this.store._arrayOfAllItems;
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
for (let j = i + 1; j < items.length; j++) {
const unread = this.store.getValue(items[j], 'unread');
const id = this.store.getValue(items[j], 'id');
if (unread > 0 && ((is_cat && id.match("CAT:")) || (!is_cat && id.match("FEED:")))) {
if (!is_cat || !(this.store.hasAttribute(items[j], 'parent_id') && this.store.getValue(items[j], 'parent_id') == feed)) return items[j];
}
}
for (let j = 0; j < i; j++) {
const unread = this.store.getValue(items[j], 'unread');
const id = this.store.getValue(items[j], 'id');
if (unread > 0 && ((is_cat && id.match("CAT:")) || (!is_cat && id.match("FEED:")))) {
if (!is_cat || !(this.store.hasAttribute(items[j], 'parent_id') && this.store.getValue(items[j], 'parent_id') == feed)) return items[j];
}
}
}
}
return null;
},
hasCats: function () {
if (this.store && this.store._itemsByIdentity)
return this.store._itemsByIdentity['CAT:-1'] != undefined;

@ -82,6 +82,9 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
if (id.match("FEED:")) {
tnode.rowNode.setAttribute('data-feed-id', bare_id);
tnode.rowNode.setAttribute('data-is-cat', "false");
const menu = new dijit.Menu();
menu.row_id = bare_id;
@ -98,6 +101,15 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
CommonDialogs.editFeed(this.getParent().row_id, false);
}}));
menu.addChild(new dijit.MenuItem({
label: __("Open site"),
onClick: function() {
App.postOpenWindow("backend.php", {op: "feeds", method: "opensite",
feed_id: this.getParent().row_id, csrf_token: __csrf_token});
}}));
menu.addChild(new dijit.MenuSeparator());
menu.addChild(new dijit.MenuItem({
label: __("Debug feed"),
onClick: function() {
@ -132,10 +144,18 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
if (id.match("CAT:")) {
tnode.loadingNode = dojo.create('img', { className: 'loadingNode', src: 'images/blank_icon.gif'});
tnode.rowNode.setAttribute('data-feed-id', bare_id);
tnode.rowNode.setAttribute('data-is-cat', "true");
tnode.loadingNode = dojo.create('img', { className: 'loadingNode', src: App.getInitParam('icon_blank')});
domConstruct.place(tnode.loadingNode, tnode.labelNode, 'after');
}
if (id.match("FEED:")) {
tnode.loadingNode = dojo.create('img', { className: 'loadingNode', src: App.getInitParam('icon_blank')});
domConstruct.place(tnode.loadingNode, tnode.expandoNode, 'only');
}
if (id.match("CAT:") && bare_id == -1) {
const menu = new dijit.Menu();
menu.row_id = bare_id;
@ -191,10 +211,15 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
return (item.unread <= 0) ? "dijitTreeLabel" : "dijitTreeLabel Unread";
},
getRowClass: function (item/*, opened */) {
let rc = "dijitTreeRow";
let rc = "dijitTreeRow dijitTreeRowFlex";
const is_cat = String(item.id).indexOf('CAT:') != -1;
if (is_cat)
rc += " Is_Cat";
else
rc += " Is_Feed";
if (!is_cat && item.error != '') rc += " Error";
if (item.unread > 0) rc += " Unread";
if (item.auxcounter > 0) rc += " Has_Aux";
@ -303,7 +328,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}, 0);
}
},
setFeedIcon: function(feed, is_cat, src) {
setIcon: function(feed, is_cat, src) {
let treeNode;
if (is_cat)
@ -313,13 +338,19 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (treeNode) {
treeNode = treeNode[0];
const icon = dojo.create('img', { src: src, className: 'icon' });
domConstruct.place(icon, treeNode.iconNode, 'only');
return true;
// could be <i material>
const icon = treeNode.iconNode.querySelector('img.icon');
if (icon) {
icon.src = src;
return true;
}
}
return false;
},
setFeedExpandoIcon: function(feed, is_cat, src) {
showLoading: function(feed, is_cat, show) {
let treeNode;
if (is_cat)
@ -329,14 +360,17 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (treeNode) {
treeNode = treeNode[0];
if (treeNode.loadingNode) {
treeNode.loadingNode.src = src;
return true;
if (show) {
treeNode.loadingNode.addClassName("visible");
treeNode.loadingNode.setAttribute("src",
is_cat ? App.getInitParam("icon_three_dots") : App.getInitParam("icon_oval"));
} else {
const icon = dojo.create('img', { src: src, className: 'loadingExpando' });
domConstruct.place(icon, treeNode.expandoNode, 'only');
return true;
treeNode.loadingNode.removeClassName("visible");
treeNode.loadingNode.setAttribute("src", App.getInitParam("icon_blank"))
}
return true
}
return false;
@ -360,7 +394,28 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
},
getNextFeed: function (feed, is_cat) {
getNextUnread: function(feed, is_cat) {
return this.getNextFeed(feed, is_cat, true);
},
_nextTreeItemFromIndex: function (start, unread_only) {
const items = this.model.store._arrayOfAllItems;
for (let i = start+1; i < items.length; i++) {
const id = String(items[i].id);
const box = this._itemNodesMap[id];
const unread = parseInt(items[i].unread);
if (box && (!unread_only || unread > 0)) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
return items[i];
}
}
}
},
getNextFeed: function (feed, is_cat, unread_only = false) {
let treeItem;
if (is_cat) {
@ -370,37 +425,43 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
const items = this.model.store._arrayOfAllItems;
let item = items[0];
const start = items.indexOf(treeItem);
if (start != -1) {
let item = this._nextTreeItemFromIndex(start, unread_only);
// let's try again from the top
// 0 (instead of -1) to skip Special category
if (!item) {
item = this._nextTreeItemFromIndex(0, unread_only);
}
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
if (item)
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
}
for (let j = i+1; j < items.length; j++) {
const id = String(items[j].id);
const box = this._itemNodesMap[id];
return [false, false];
},
_prevTreeItemFromIndex: function (start, unread_only) {
const items = this.model.store._arrayOfAllItems;
if (box) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
for (let i = start-1; i > 0; i--) {
const id = String(items[i].id);
const box = this._itemNodesMap[id];
const unread = parseInt(items[i].unread);
if (Element.visible(cat) && Element.visible(row)) {
item = items[j];
break;
}
}
if (box && (!unread_only || unread > 0)) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
return items[i];
}
break;
}
}
if (item) {
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
} else {
return false;
}
},
getPreviousFeed: function (feed, is_cat) {
getPreviousFeed: function (feed, is_cat, unread_only = false) {
let treeItem;
if (is_cat) {
@ -410,37 +471,22 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
}
const items = this.model.store._arrayOfAllItems;
let item = items[0] == treeItem ? items[items.length-1] : items[0];
const start = items.indexOf(treeItem);
for (let i = 0; i < items.length; i++) {
if (items[i] == treeItem) {
if (start != -1) {
let item = this._prevTreeItemFromIndex(start, unread_only);
for (let j = i-1; j > 0; j--) {
const id = String(items[j].id);
const box = this._itemNodesMap[id];
if (box) {
const row = box[0].rowNode;
const cat = box[0].rowNode.parentNode.parentNode;
if (Element.visible(cat) && Element.visible(row)) {
item = items[j];
break;
}
}
}
break;
// wrap from the bottom
if (!item) {
item = this._prevTreeItemFromIndex(items.length, unread_only);
}
}
if (item) {
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
} else {
return false;
if (item)
return [this.model.store.getValue(item, 'bare_id'),
!this.model.store.getValue(item, 'id').match('FEED:')];
}
return [false, false];
},
getFeedCategory: function(feed) {
try {

@ -113,23 +113,30 @@ const Feeds = {
this.hideOrShowFeeds(App.getInitParam("hide_read_feeds"));
this._counters_prev = elems;
PluginHost.run(PluginHost.HOOK_COUNTERS_PROCESSED);
PluginHost.run(PluginHost.HOOK_COUNTERS_PROCESSED, elems);
},
reloadCurrent: function(method) {
if (this.getActive() != undefined) {
console.log("reloadCurrent: " + method);
console.log("reloadCurrent", this.getActive(), this.activeIsCat(), method);
this.open({feed: this.getActive(), is_cat: this.activeIsCat(), method: method});
}
return false; // block unneeded form submits
},
openDefaultFeed: function() {
this.open({feed: this._default_feed_id});
},
onViewModeChanged: function() {
// TODO: is this still needed?
App.find("body").setAttribute("view-mode",
dijit.byId("toolbar-main").getValues().view_mode);
return Feeds.reloadCurrent('');
},
openNextUnread: function() {
const is_cat = this.activeIsCat();
const nuf = this.getNextUnread(this.getActive(), is_cat);
if (nuf) this.open({feed: nuf, is_cat: is_cat});
const [feed, is_cat] = this.getNextUnread(this.getActive(), this.activeIsCat());
if (feed !== false)
this.open({feed: feed, is_cat: is_cat});
},
toggle: function() {
Element.toggle("feeds-holder");
@ -236,12 +243,12 @@ const Feeds = {
//document.onkeypress = (event) => { return App.hotkeyHandler(event) };
window.onresize = () => { Headlines.scrollHandler(); }
/* global hash_get */
const hash_feed_id = hash_get('f');
const hash_feed_is_cat = hash_get('c') == "1";
const hash = App.Hash.get();
console.log('got hash', hash);
if (hash_feed_id != undefined) {
this.open({feed: hash_feed_id, is_cat: hash_feed_is_cat});
if (hash.f != undefined) {
this.open({feed: parseInt(hash.f), is_cat: parseInt(hash.c)});
} else {
this.openDefaultFeed();
}
@ -305,15 +312,22 @@ const Feeds = {
setActive: function(id, is_cat) {
console.log('setActive', id, is_cat);
/* global hash_set */
hash_set('f', id);
hash_set('c', is_cat ? 1 : 0);
window.requestIdleCallback(() => {
App.Hash.set({f: id, c: is_cat ? 1 : 0});
});
this._active_feed_id = id;
this._active_feed_is_cat = is_cat;
App.byId("headlines-frame").setAttribute("feed-id", id);
App.byId("headlines-frame").setAttribute("is-cat", is_cat ? 1 : 0);
const container = App.byId("headlines-frame");
// TODO @deprecated: these two should be removed (replaced with data- attributes below)
container.setAttribute("feed-id", id);
container.setAttribute("is-cat", is_cat ? 1 : 0);
// ^
container.setAttribute("data-feed-id", id);
container.setAttribute("data-is-cat", is_cat ? "true" : "false");
this.select(id, is_cat);
@ -366,10 +380,7 @@ const Feeds = {
}, 10 * 1000);
}
//Form.enable("toolbar-main");
let query = Object.assign({op: "feeds", method: "view", feed: feed},
dojo.formToObject("toolbar-main"));
let query = {...{op: "feeds", method: "view", feed: feed}, ...dojo.formToObject("toolbar-main")};
if (method) query.m = method;
@ -389,21 +400,20 @@ const Feeds = {
query.m = "ForceUpdate";
}
if (!delayed)
if (!this.setExpando(feed, is_cat,
(is_cat) ? 'images/indicator_tiny.gif' : 'images/indicator_white.gif'))
Notify.progress("Loading, please wait...", true);
query.cat = is_cat;
this.setActive(feed, is_cat);
window.clearTimeout(this._viewfeed_wait_timeout);
this._viewfeed_wait_timeout = window.setTimeout(() => {
this.showLoading(feed, is_cat, true);
//Notify.progress("Loading, please wait...", true);*/
xhr.json("backend.php", query, (reply) => {
try {
window.clearTimeout(this._infscroll_timeout);
this.setExpando(feed, is_cat, 'images/blank_icon.gif');
this.showLoading(feed, is_cat, false);
Headlines.onLoaded(reply, offset, append);
PluginHost.run(PluginHost.HOOK_FEED_LOADED, [feed, is_cat]);
} catch (e) {
@ -469,10 +479,10 @@ const Feeds = {
// only select next unread feed if catching up entirely (as opposed to last week etc)
if (show_next_feed && !mode) {
const nuf = this.getNextUnread(feed, is_cat);
const [next_feed, next_is_cat] = this.getNextUnread(feed, is_cat);
if (nuf) {
this.open({feed: nuf, is_cat: is_cat});
if (next_feed !== false) {
this.open({feed: next_feed, is_cat: next_is_cat});
}
} else if (feed == this.getActive() && is_cat == this.activeIsCat()) {
this.reloadCurrent();
@ -516,7 +526,7 @@ const Feeds = {
const tree = dijit.byId("feedTree");
if (tree && tree.model)
return tree._cat_of_feed(feed);
return tree.getFeedCategory(feed);
} catch (e) {
//
@ -564,21 +574,35 @@ const Feeds = {
setIcon: function(feed, is_cat, src) {
const tree = dijit.byId("feedTree");
if (tree) return tree.setFeedIcon(feed, is_cat, src);
if (tree) return tree.setIcon(feed, is_cat, src);
},
setExpando: function(feed, is_cat, src) {
showLoading: function(feed, is_cat, show) {
const tree = dijit.byId("feedTree");
if (tree) return tree.setFeedExpandoIcon(feed, is_cat, src);
if (tree) return tree.showLoading(feed, is_cat, show);
return false;
},
getNextFeed: function(feed, is_cat, unread_only = false) {
const tree = dijit.byId("feedTree");
if (tree) return tree.getNextFeed(feed, is_cat, unread_only);
return [false, false];
},
getPreviousFeed: function(feed, is_cat, unread_only = false) {
const tree = dijit.byId("feedTree");
if (tree) return tree.getPreviousFeed(feed, is_cat, unread_only);
return [false, false];
},
getNextUnread: function(feed, is_cat) {
const tree = dijit.byId("feedTree");
const nuf = tree.model.getNextUnreadFeed(feed, is_cat);
if (nuf)
return tree.model.store.getValue(nuf, 'bare_id');
if (tree) return tree.getNextUnread(feed, is_cat);
return [false, false];
},
search: function() {
xhr.json("backend.php",
@ -612,7 +636,7 @@ const Feeds = {
{class: 'alt-info pull-left', onclick: "window.open('https://tt-rss.org/wiki/SearchSyntax')"})}
` : ''}
${App.FormFields.submit_tag(__('Search'), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.submit_tag(App.FormFields.icon("search") + " " + __('Search'), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>

@ -17,17 +17,27 @@ const Headlines = {
sticky_header_observer: new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
const header = entry.target.nextElementSibling;
const header = entry.target.closest('.cdm').querySelector(".header");
if (entry.intersectionRatio == 0) {
header.setAttribute("stuck", "1");
} else if (entry.intersectionRatio == 1) {
header.removeAttribute("stuck");
if (entry.isIntersecting) {
header.removeAttribute("data-is-stuck");
} else {
header.setAttribute("data-is-stuck", "true");
}
//console.log(entry.target, header, entry.intersectionRatio);
//console.log(entry.target, entry.intersectionRatio, entry.isIntersecting, entry.boundingClientRect.top);
});
},
{threshold: [0, 1], root: document.querySelector("#headlines-frame")}
),
sticky_content_observer: new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
const header = entry.target.closest('.cdm').querySelector(".header");
header.style.position = entry.isIntersecting ? "sticky" : "unset";
//console.log(entry.target, entry.intersectionRatio, entry.isIntersecting, entry.boundingClientRect.top);
});
},
{threshold: [0, 1], root: document.querySelector("#headlines-frame")}
@ -72,14 +82,13 @@ const Headlines = {
}
});
PluginHost.run(PluginHost.HOOK_HEADLINE_MUTATIONS, mutations);
Headlines.updateSelectedPrompt();
if ('requestIdleCallback' in window)
window.requestIdleCallback(() => {
Headlines.syncModified(modified);
});
else
window.requestIdleCallback(() => {
Headlines.syncModified(modified);
});
}),
syncModified: function (modified) {
const ops = {
@ -173,14 +182,14 @@ const Headlines = {
});
}
Promise.all(promises).then((results) => {
Promise.allSettled(promises).then((results) => {
let feeds = [];
let labels = [];
results.forEach((res) => {
if (res) {
try {
const obj = JSON.parse(res);
const obj = JSON.parse(res.value);
if (obj.feeds)
feeds = feeds.concat(obj.feeds);
@ -198,6 +207,8 @@ const Headlines = {
console.log('requesting counters for', feeds, labels);
Feeds.requestCounters(feeds, labels);
}
PluginHost.run(PluginHost.HOOK_HEADLINE_MUTATIONS_SYNCED, results);
});
},
click: function (event, id, in_body) {
@ -278,7 +289,7 @@ const Headlines = {
}
},
loadMore: function () {
const view_mode = document.forms["toolbar-main"].view_mode.value;
const view_mode = dijit.byId("toolbar-main").getValues().view_mode;
const unread_in_buffer = App.findAll("#headlines-frame > div[id*=RROW][class*=Unread]").length;
const num_all = App.findAll("#headlines-frame > div[id*=RROW]").length;
const num_unread = Feeds.getUnread(Feeds.getActive(), Feeds.activeIsCat());
@ -340,8 +351,7 @@ const Headlines = {
// invoke lazy load if last article in buffer is nearly visible OR is active
if (Article.getActive() == last_row.getAttribute("data-article-id") || last_row.offsetTop - 250 <= container.scrollTop + container.offsetHeight) {
hsp.innerHTML = "<span class='loading'><img src='images/indicator_tiny.gif'> " +
__("Loading, please wait...") + "</span>";
hsp.innerHTML = `<span class='text-muted text-small text-center'><img class="icon-three-dots" src="${App.getInitParam('icon_three_dots')}"> ${__("Loading, please wait...")}</span>`;
Headlines.loadMore();
return;
@ -371,6 +381,9 @@ const Headlines = {
}
}
}
PluginHost.run(PluginHost.HOOK_HEADLINES_SCROLL_HANDLER);
} catch (e) {
console.warn("scrollHandler", e);
}
@ -378,11 +391,17 @@ const Headlines = {
objectById: function (id) {
return this.headlines[id];
},
setCommonClasses: function () {
App.byId("headlines-frame").removeClassName("cdm");
App.byId("headlines-frame").removeClassName("normal");
setCommonClasses: function (headlines_count) {
const container = App.byId("headlines-frame");
container.removeClassName("cdm");
container.removeClassName("normal");
App.byId("headlines-frame").addClassName(App.isCombinedMode() ? "cdm" : "normal");
container.addClassName(App.isCombinedMode() ? "cdm" : "normal");
container.setAttribute("data-enable-grid", App.getInitParam("cdm_enable_grid") ? "true" : "false");
container.setAttribute("data-headlines-count", parseInt(headlines_count));
container.setAttribute("data-is-cdm", App.isCombinedMode() ? "true" : "false");
container.setAttribute("data-is-cdm-expanded", App.getInitParam("cdm_expanded"));
// for floating title because it's placed outside of headlines-frame
App.byId("main").removeClassName("expandable");
@ -393,7 +412,7 @@ const Headlines = {
},
renderAgain: function () {
// TODO: wrap headline elements into a knockoutjs model to prevent all this stuff
Headlines.setCommonClasses();
Headlines.setCommonClasses(this.headlines.filter((h) => h.id).length);
App.findAll("#headlines-frame > div[id*=RROW]").forEach((row) => {
const id = row.getAttribute("data-article-id");
@ -422,11 +441,18 @@ const Headlines = {
this.sticky_header_observer.observe(e)
});
App.findAll(".cdm .content").forEach((e) => {
this.sticky_content_observer.observe(e)
});
if (App.getInitParam("cdm_expanded"))
App.findAll("#headlines-frame > div[id*=RROW].cdm").forEach((e) => {
this.unpack_observer.observe(e)
});
dijit.byId('main').resize();
PluginHost.run(PluginHost.HOOK_HEADLINES_RENDERED);
},
render: function (headlines, hl) {
let row = null;
@ -464,7 +490,10 @@ const Headlines = {
id="RROW-${hl.id}"
data-article-id="${hl.id}"
data-orig-feed-id="${hl.feed_id}"
data-orig-feed-title="${App.escapeHtml(hl.feed_title)}"
data-is-packed="1"
data-content="${App.escapeHtml(hl.content)}"
data-rendered-enclosures="${App.escapeHtml(Article.renderEnclosures(hl.enclosures))}"
data-score="${hl.score}"
data-article-title="${App.escapeHtml(hl.title)}"
onmouseover="Article.mouseIn(${hl.id})"
@ -486,7 +515,7 @@ const Headlines = {
${hl.cdm_excerpt ? hl.cdm_excerpt : ""}
</span>
<div class="feed">
<div class="feed vfeedMenuAttach" data-feed-id="${hl.feed_id}">
<a href="#" style="background-color: ${hl.feed_bg_color}"
onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}</a>
</div>
@ -494,9 +523,10 @@ const Headlines = {
<span class="updated" title="${hl.imported}">${hl.updated}</span>
<div class="right">
<i class="material-icons icon-grid-span" title="${__("Span all columns")}" onclick="Article.cdmToggleGridSpan(${hl.id})">fullscreen</i>
<i class="material-icons icon-score" title="${hl.score}" onclick="Article.setScore(${hl.id}, this)">${Article.getScorePic(hl.score)}</i>
<span style="cursor : pointer" title="${App.escapeHtml(hl.feed_title)}" onclick="Feeds.open({feed:${hl.feed_id}})">
<span class="icon-feed" title="${App.escapeHtml(hl.feed_title)}" onclick="Feeds.open({feed:${hl.feed_id}})">
${Feeds.renderIcon(hl.feed_id, hl.has_icon)}
</span>
</div>
@ -506,11 +536,14 @@ const Headlines = {
<div class="content" onclick="return Headlines.click(event, ${hl.id}, true);">
${Article.renderNote(hl.id, hl.note)}
<div class="content-inner" lang="${hl.lang ? hl.lang : 'en'}">
<img src="${App.getInitParam('icon_indicator_white')}">
</div>
<div class="intermediate">
${Article.renderEnclosures(hl.enclosures)}
<div class="text-center text-muted">
${__("Loading, please wait...")}
</div>
</div>
<!-- intermediate: unstyled, kept for compatibility -->
<div class="intermediate"></div>
<div class="footer" onclick="event.stopPropagation()">
<div class="left">
@ -534,6 +567,7 @@ const Headlines = {
row = `<div class="hl ${row_class} ${Article.getScoreClass(hl.score)}"
id="RROW-${hl.id}"
data-orig-feed-id="${hl.feed_id}"
data-orig-feed-title="${App.escapeHtml(hl.feed_title)}"
data-article-id="${hl.id}"
data-score="${hl.score}"
data-article-title="${App.escapeHtml(hl.title)}"
@ -552,15 +586,15 @@ const Headlines = {
${Article.renderLabels(hl.id, hl.labels)}
</span>
</div>
<span class="feed">
<a style="background : ${hl.feed_bg_color}" href="#" onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}</a>
</span>
<span class="feed vfeedMenuAttach" data-feed-id="${hl.feed_id}">
<a style="background : ${hl.feed_bg_color}" href="#" onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}</a>
</span>
<div title="${hl.imported}">
<span class="updated">${hl.updated}</span>
</div>
<div class="right">
<i class="material-icons icon-score" title="${hl.score}" onclick="Article.setScore(${hl.id}, this)">${Article.getScorePic(hl.score)}</i>
<span onclick="Feeds.open({feed:${hl.feed_id}})" style="cursor : pointer" title="${App.escapeHtml(hl.feed_title)}">${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</span>
<span onclick="Feeds.open({feed:${hl.feed_id}})" class="icon-feed" title="${App.escapeHtml(hl.feed_title)}">${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</span>
</div>
</div>
`;
@ -614,7 +648,7 @@ const Headlines = {
</span>
<span class='right'>
<span id='selected_prompt'></span>
<div dojoType='fox.form.DropDownButton' title='"${__('Select articles')}'>
<div class='select-articles-dropdown' dojoType='fox.form.DropDownButton' title='"${__('Select articles')}'>
<span>${__("Select...")}</span>
<div dojoType='dijit.Menu' style='display: none;'>
<div dojoType='dijit.MenuItem' onclick='Headlines.select("all")'>${__('All')}</div>
@ -671,11 +705,15 @@ const Headlines = {
console.log('infscroll_disabled=', Feeds.infscroll_disabled);
// also called in renderAgain() after view mode switch
Headlines.setCommonClasses();
Headlines.setCommonClasses(headlines_count);
/** TODO: remove @deprecated */
App.byId("headlines-frame").setAttribute("is-vfeed",
reply['headlines']['is_vfeed'] ? 1 : 0);
App.byId("headlines-frame").setAttribute("data-is-vfeed",
reply['headlines']['is_vfeed'] ? "true" : "false");
Article.setActive(0);
try {
@ -716,6 +754,9 @@ const Headlines = {
hsp.id = "headlines-spacer";
}
// clear out hsp contents in case there's a power-hungry svg icon rotating there
hsp.innerHTML = "";
dijit.byId('headlines-frame').domNode.appendChild(hsp);
this.initHeadlinesMenu();
@ -767,6 +808,9 @@ const Headlines = {
hsp.id = "headlines-spacer";
}
// clear out hsp contents in case there's a power-hungry svg icon rotating there
hsp.innerHTML = "";
c.domNode.appendChild(hsp);
this.initHeadlinesMenu();
@ -799,6 +843,10 @@ const Headlines = {
this.sticky_header_observer.observe(e)
});
App.findAll(".cdm .content").forEach((e) => {
this.sticky_content_observer.observe(e)
});
if (App.getInitParam("cdm_expanded"))
App.findAll("#headlines-frame > div[id*=RROW].cdm").forEach((e) => {
this.unpack_observer.observe(e)
@ -816,22 +864,22 @@ const Headlines = {
// unpack visible articles, fill buffer more, etc
this.scrollHandler();
dijit.byId('main').resize();
PluginHost.run(PluginHost.HOOK_HEADLINES_RENDERED);
Notify.close();
},
reverse: function () {
const toolbar = document.forms["toolbar-main"];
const order_by = dijit.getEnclosingWidget(toolbar.order_by);
let value = order_by.attr('value');
const toolbar = dijit.byId("toolbar-main");
let order_by = toolbar.getValues().order_by;
if (value != "date_reverse")
value = "date_reverse";
if (order_by != "date_reverse")
order_by = "date_reverse";
else
value = "default";
order_by.attr('value', value);
order_by = App.getInitParam("default_view_order_by");
Feeds.reloadCurrent();
toolbar.setValues({order_by: order_by});
},
selectionToggleUnread: function (params = {}) {
const cmode = params.cmode != undefined ? params.cmode : 2;
@ -1455,6 +1503,48 @@ const Headlines = {
menu.startup();
}
/* vfeed menu */
if (!dijit.byId("vfeedMenu")) {
const menu = new dijit.Menu({
id: "vfeedMenu",
targetNodeIds: ["headlines-frame"],
selector: ".vfeedMenuAttach"
});
menu.addChild(new dijit.MenuItem({
label: __("Mark as read"),
onClick: function() {
Feeds.catchupFeed(this.getParent().currentTarget.getAttribute("data-feed-id"));
}}));
menu.addChild(new dijit.MenuItem({
label: __("Edit feed"),
onClick: function() {
CommonDialogs.editFeed(this.getParent().currentTarget.getAttribute("data-feed-id"), false);
}}));
menu.addChild(new dijit.MenuItem({
label: __("Open site"),
onClick: function() {
App.postOpenWindow("backend.php", {op: "feeds", method: "opensite",
feed_id: this.getParent().currentTarget.getAttribute("data-feed-id"), csrf_token: __csrf_token});
}}));
menu.addChild(new dijit.MenuSeparator());
menu.addChild(new dijit.MenuItem({
label: __("Debug feed"),
onClick: function() {
/* global __csrf_token */
App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger",
feed_id: this.getParent().currentTarget.getAttribute("data-feed-id"), csrf_token: __csrf_token});
}}));
menu.startup();
}
/* vgroup feed title menu */
if (!dijit.byId("headlinesFeedTitleMenu")) {

@ -17,6 +17,10 @@ const PluginHost = {
HOOK_HEADLINE_RENDERED: 12,
HOOK_COUNTERS_RECEIVED: 13,
HOOK_COUNTERS_PROCESSED: 14,
HOOK_HEADLINE_MUTATIONS: 15,
HOOK_HEADLINE_MUTATIONS_SYNCED: 16,
HOOK_HEADLINES_RENDERED: 17,
HOOK_HEADLINES_SCROLL_HANDLER: 18,
hooks: [],
register: function (name, callback) {
if (typeof(this.hooks[name]) == 'undefined')
@ -25,7 +29,7 @@ const PluginHost = {
this.hooks[name].push(callback);
},
run: function (name, args) {
//console.warn('PluginHost::run ' + name);
//console.warn('PluginHost.run', name);
if (typeof(this.hooks[name]) != 'undefined')
for (let i = 0; i < this.hooks[name].length; i++) {

@ -513,7 +513,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_b
<div class='panel panel-scrollable'>
<table width='100%' id='inactive-feeds-list'>
${reply.map((row) => `<tr data-row-id='${row.id}'>
<td width='5%' align='center'>
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td>

@ -128,6 +128,24 @@ const Helpers = {
getSelectedProfiles: function () {
return Tables.getSelected("pref-profiles-list");
},
cloneSelected: function() {
const sel_rows = this.getSelectedProfiles();
if (sel_rows.length == 1) {
const new_title = prompt(__("Name for cloned profile:"));
if (new_title) {
xhr.post("backend.php", {op: "pref-prefs", method: "cloneprofile", "new_title": new_title, "old_profile": sel_rows[0]}, () => {
Notify.close();
dialog.refresh();
});
}
} else {
alert(__("Please select a single profile to clone."));
}
},
removeSelected: function () {
const sel_rows = this.getSelectedProfiles();
@ -135,12 +153,7 @@ const Helpers = {
if (confirm(__("Remove selected profiles? Active and default profiles will not be removed."))) {
Notify.progress("Removing selected profiles...", true);
const query = {
op: "pref-prefs", method: "remprofiles",
ids: sel_rows.toString()
};
xhr.post("backend.php", query, () => {
xhr.post("backend.php", {op: "pref-prefs", method: "remprofiles", "ids[]": sel_rows}, () => {
Notify.close();
dialog.refresh();
});
@ -179,7 +192,7 @@ const Helpers = {
<div class="pull-right">
<input name='newprofile' dojoType='dijit.form.ValidationTextBox' required='1'>
${App.FormFields.button_tag(__('Create profile'), "", {onclick: 'App.dialogOf(this).addProfile()'})}
${App.FormFields.button_tag(App.FormFields.icon("add_circle") + " " + __('Add'), "", {onclick: 'App.dialogOf(this).addProfile()'})}
</div>
</div>
@ -188,7 +201,7 @@ const Helpers = {
<table width='100%' id='pref-profiles-list'>
${reply.map((profile) => `
<tr data-row-id="${profile.id}">
<td width='5%'>
<td class='checkbox'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Tables.onRowChecked(this)'})}
</td>
<td>
@ -203,6 +216,7 @@ const Helpers = {
</script>
</span>` : `${profile.title}`}
${profile.active ? __("(active)") : ""}
${profile.initialized ? "" : __("(empty)")}
</td>
</tr>
`).join("")}
@ -210,9 +224,11 @@ const Helpers = {
</div>
<footer>
${App.FormFields.button_tag(__('Remove selected profiles'), "",
${App.FormFields.button_tag(App.FormFields.icon("delete") + " " +__('Remove selected'), "",
{class: 'pull-left alt-danger', onclick: 'App.dialogOf(this).removeSelected()'})}
${App.FormFields.submit_tag(__('Activate profile'), {onclick: 'App.dialogOf(this).execute()'})}
${App.FormFields.button_tag(App.FormFields.icon("content_copy") + " " + __('Clone'), "",
{class: '', onclick: 'App.dialogOf(this).cloneSelected()'})}
${App.FormFields.submit_tag(App.FormFields.icon("check") + " " + __('Activate'), {onclick: 'App.dialogOf(this).execute()'})}
${App.FormFields.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>
@ -244,58 +260,70 @@ const Helpers = {
},
Prefs: {
customizeCSS: function() {
xhr.json("backend.php", {op: "pref-prefs", method: "customizeCSS"}, (reply) => {
const dialog = new fox.SingleUseDialog({
title: __("Customize stylesheet"),
apply: function() {
xhr.post("backend.php", this.attr('value'), () => {
Element.show("css_edit_apply_msg");
App.byId("user_css_style").innerText = this.attr('value');
});
},
execute: function () {
Notify.progress('Saving data...', true);
const dialog = new fox.SingleUseDialog({
title: __("Customize stylesheet"),
apply: function() {
xhr.post("backend.php", this.attr('value'), () => {
Element.show("css_edit_apply_msg");
App.byId("user_css_style").innerText = this.attr('value');
});
},
execute: function () {
Notify.progress('Saving data...', true);
xhr.post("backend.php", this.attr('value'), () => {
window.location.reload();
});
},
content: `
<div class='alert alert-info'>
${__("You can override colors, fonts and layout of your currently selected theme with custom CSS declarations here.")}
</div>
xhr.post("backend.php", this.attr('value'), () => {
window.location.reload();
});
},
content: `
<div class='alert alert-info'>
${__("You can override colors, fonts and layout of your currently selected theme with custom CSS declarations here.")}
</div>
${App.FormFields.hidden_tag('op', 'rpc')}
${App.FormFields.hidden_tag('method', 'setpref')}
${App.FormFields.hidden_tag('key', 'USER_STYLESHEET')}
${App.FormFields.hidden_tag('op', 'rpc')}
${App.FormFields.hidden_tag('method', 'setpref')}
${App.FormFields.hidden_tag('key', 'USER_STYLESHEET')}
<div id='css_edit_apply_msg' style='display : none'>
<div class='alert alert-warning'>
${__("User CSS has been applied, you might need to reload the page to see all changes.")}
</div>
<div id='css_edit_apply_msg' style='display : none'>
<div class='alert alert-warning'>
${__("User CSS has been applied, you might need to reload the page to see all changes.")}
</div>
</div>
<textarea class='panel user-css-editor' dojoType='dijit.form.SimpleTextarea'
style='font-size : 12px;' name='value'>${reply.value}</textarea>
<footer>
<button dojoType='dijit.form.Button' class='alt-success' onclick="App.dialogOf(this).apply()">
${__('Apply')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${__('Save and reload')}
</button>
<button dojoType='dijit.form.Button' onclick="App.dialogOf(this).hide()">
${__('Cancel')}
</button>
</footer>
`
});
<textarea class='panel user-css-editor' disabled='true' dojoType='dijit.form.SimpleTextarea'
style='font-size : 12px;' name='value'>${__("Loading, please wait...")}</textarea>
dialog.show();
<footer>
<button dojoType='dijit.form.Button' class='alt-success' onclick="App.dialogOf(this).apply()">
${App.FormFields.icon("check")}
${__('Apply')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${App.FormFields.icon("refresh")}
${__('Save and reload')}
</button>
<button dojoType='dijit.form.Button' onclick="App.dialogOf(this).hide()">
${__('Cancel')}
</button>
</footer>
`
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhr.json("backend.php", {op: "pref-prefs", method: "customizeCSS"}, (reply) => {
const editor = dijit.getEnclosingWidget(dialog.domNode.querySelector(".user-css-editor"));
editor.attr('value', reply.value);
editor.attr('disabled', false);
});
});
dialog.show();
},
confirmReset: function() {
if (confirm(__("Reset to defaults?"))) {
@ -313,8 +341,100 @@ const Helpers = {
},
},
Plugins: {
clearPluginData: function(name) {
if (confirm(__("Clear stored data for this plugin?"))) {
_list_of_plugins: [],
_search_query: "",
enableSelected: function() {
const form = dijit.byId("changePluginsForm");
if (form.validate()) {
xhr.post("backend.php", form.getValues(), () => {
Notify.close();
if (confirm(__('Selected plugins have been enabled. Reload?'))) {
window.location.reload();
}
})
}
},
search: function() {
this._search_query = dijit.byId("changePluginsForm").getValues().search;
this.render_contents();
},
reload: function() {
xhr.json("backend.php", {op: "pref-prefs", method: "getPluginsList"}, (reply) => {
this._list_of_plugins = reply;
this.render_contents();
});
},
render_contents: function() {
const container = document.querySelector(".prefs-plugin-list");
container.innerHTML = "";
let results_rendered = 0;
const is_admin = this._list_of_plugins.is_admin;
const search_tokens = this._search_query
.split(/ {1,}/)
.filter((stoken) => (stoken.length > 0 ? stoken : null));
this._list_of_plugins.plugins.forEach((plugin) => {
if (search_tokens.length == 0 ||
Object.values(plugin).filter((pval) =>
search_tokens.filter((stoken) =>
(pval.toString().indexOf(stoken) != -1 ? stoken : null)
).length == search_tokens.length).length > 0) {
++results_rendered;
// only user-enabled actually counts in the checkbox when saving because system plugin checkboxes are disabled (see below)
container.innerHTML += `
<li data-row-value="${App.escapeHtml(plugin.name)}" data-plugin-local="${plugin.is_local}"
data-plugin-name="${App.escapeHtml(plugin.name)}" title="${plugin.is_system ? __("System plugins are enabled using global configuration.") : ""}">
<label class="checkbox ${plugin.is_system ? "system text-info" : ""}">
${App.FormFields.checkbox_tag("plugins[]", plugin.user_enabled || plugin.system_enabled, plugin.name,
{disabled: plugin.is_system})}</div>
<span class='name'>${plugin.name}:</span>
<span class="description ${plugin.is_system ? "text-info" : ""}">
${plugin.description}
</span>
</label>
<div class='actions'>
${plugin.is_system ?
App.FormFields.button_tag(App.FormFields.icon("security"), "",
{disabled: true}) : ''}
${plugin.more_info ?
App.FormFields.button_tag(App.FormFields.icon("help"), "",
{class: 'alt-info', onclick: `window.open("${App.escapeHtml(plugin.more_info)}")`}) : ''}
${is_admin && plugin.is_local ?
App.FormFields.button_tag(App.FormFields.icon("update"), "",
{title: __("Update"), class: 'alt-warning', "data-update-btn-for-plugin": plugin.name, style: 'display : none',
onclick: `Helpers.Plugins.update("${App.escapeHtml(plugin.name)}")`}) : ''}
${is_admin && plugin.has_data ?
App.FormFields.button_tag(App.FormFields.icon("clear"), "",
{title: __("Clear data"), onclick: `Helpers.Plugins.clearData("${App.escapeHtml(plugin.name)}")`}) : ''}
${is_admin && plugin.is_local ?
App.FormFields.button_tag(App.FormFields.icon("delete"), "",
{title: __("Uninstall"), onclick: `Helpers.Plugins.uninstall("${App.escapeHtml(plugin.name)}")`}) : ''}
</div>
<div class='version text-muted'>${plugin.version}</div>
</li>
`;
} else {
// if plugin is outside of search scope, keep current value in case of saving (only user-enabled is needed)
container.innerHTML += App.FormFields.checkbox_tag("plugins[]", plugin.user_enabled, plugin.name, {style: 'display : none'});
}
});
if (results_rendered == 0) {
container.innerHTML += `<li class='text-center text-info'>${__("Could not find any plugins for this search query.")}</li>`;
}
dojo.parser.parse(container);
},
clearData: function(name) {
if (confirm(__("Clear stored data for %s?").replace("%s", name))) {
Notify.progress("Loading, please wait...");
xhr.post("backend.php", {op: "pref-prefs", method: "clearPluginData", name: name}, () => {
@ -322,39 +442,199 @@ const Helpers = {
});
}
},
checkForUpdate: function(name = null) {
Notify.progress("Checking for plugin updates...");
uninstall: function(plugin) {
const msg = __("Uninstall plugin %s?").replace("%s", plugin);
xhr.json("backend.php", {op: "pref-prefs", method: "checkForPluginUpdates", name: name}, (reply) => {
Notify.close();
if (confirm(msg)) {
Notify.progress("Loading, please wait...");
xhr.json("backend.php", {op: "pref-prefs", method: "uninstallPlugin", plugin: plugin}, (reply) => {
if (reply && reply.status == 1)
Helpers.Prefs.refresh();
else {
Notify.error("Plugin uninstallation failed.");
}
});
}
},
install: function() {
const dialog = new fox.SingleUseDialog({
PI_RES_ALREADY_INSTALLED: "PI_RES_ALREADY_INSTALLED",
PI_RES_SUCCESS: "PI_RES_SUCCESS",
PI_ERR_NO_CLASS: "PI_ERR_NO_CLASS",
PI_ERR_NO_INIT_PHP: "PI_ERR_NO_INIT_PHP",
PI_ERR_EXEC_FAILED: "PI_ERR_EXEC_FAILED",
PI_ERR_NO_TEMPDIR: "PI_ERR_NO_TEMPDIR",
PI_ERR_PLUGIN_NOT_FOUND: "PI_ERR_PLUGIN_NOT_FOUND",
PI_ERR_NO_WORKDIR: "PI_ERR_NO_WORKDIR",
title: __("Available plugins"),
need_refresh: false,
entries: false,
search_query: "",
installed_plugins: [],
onHide: function() {
if (this.need_refresh) {
Helpers.Prefs.refresh();
}
},
performInstall: function(plugin) {
const install_dialog = new fox.SingleUseDialog({
title: __("Plugin installer"),
content: `
<ul class="panel panel-scrollable contents">
<li class='text-center'>${__("Installing %s, please wait...").replace("%s", plugin)}</li>
</ul>
<footer class='text-center'>
${App.FormFields.submit_tag(__("Close this window"))}
</footer>`
});
const tmph = dojo.connect(install_dialog, 'onShow', function () {
dojo.disconnect(tmph);
const container = install_dialog.domNode.querySelector(".contents");
xhr.json("backend.php", {op: "pref-prefs", method: "installPlugin", plugin: plugin}, (reply) => {
if (!reply) {
container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
} else {
switch (reply.result) {
case dialog.PI_RES_SUCCESS:
container.innerHTML = `<li class='text-success text-center'>${__("Plugin has been installed.")}</li>`
dialog.need_refresh = true;
break;
case dialog.PI_RES_ALREADY_INSTALLED:
container.innerHTML = `<li class='text-success text-center'>${__("Plugin is already installed.")}</li>`
break;
default:
container.innerHTML = `
<li>
<h3 style="margin-top: 0">${plugin}</h3>
<div class='text-error'>${reply.result}</div>
${reply.stderr ? `<pre class="small text-error pre-wrap">${reply.stderr}</pre>` : ''}
${reply.stdour ? `<pre class="small text-success pre-wrap">${reply.stdout}</pre>` : ''}
<p class="small">
${App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", reply.git_status)}
</p>
</li>
`;
}
}
});
});
install_dialog.show();
},
search: function() {
this.search_query = this.attr('value').search.toLowerCase();
window.requestIdleCallback(() => {
this.render_contents();
});
},
render_contents: function() {
const container = dialog.domNode.querySelector(".contents");
if (!dialog.entries) {
container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
} else {
container.innerHTML = "";
let results_rendered = 0;
const search_tokens = dialog.search_query
.split(/ {1,}/)
.filter((stoken) => (stoken.length > 0 ? stoken : null));
dialog.entries.forEach((plugin) => {
const is_installed = (dialog.installed_plugins
.filter((p) => plugin.topics.map((t) => t.replace(/-/g, "_")).includes(p))).length > 0;
if (search_tokens.length == 0 ||
Object.values(plugin).filter((pval) =>
search_tokens.filter((stoken) =>
(pval.indexOf(stoken) != -1 ? stoken : null)
).length == search_tokens.length).length > 0) {
++results_rendered;
if (reply) {
let plugins_with_updates = 0;
container.innerHTML += `
<li data-row-value="${App.escapeHtml(plugin.name)}" class="${is_installed ? "plugin-installed" : ""}">
${App.FormFields.button_tag((is_installed ?
App.FormFields.icon("check") + " " +__("Already installed") :
App.FormFields.icon("file_download") + " " +__('Install')), "", {class: 'alt-primary pull-right',
disabled: is_installed,
onclick: `App.dialogOf(this).performInstall("${App.escapeHtml(plugin.name)}")`})}
reply.forEach((p) => {
if (p.rv.o) {
const button = dijit.getEnclosingWidget(App.find(`*[data-update-btn-for-plugin="${p.plugin}"]`));
<h3>${plugin.name}
<a target="_blank" href="${App.escapeHtml(plugin.html_url)}">
${App.FormFields.icon("open_in_new_window")}
</a>
</h3>
if (button) {
button.domNode.show();
++plugins_with_updates;
<div class='small text-muted'>${__("Updated: %s").replace("%s", plugin.last_update)}</div>
<div class='description'>${plugin.description}</div>
</li>
`
}
});
if (results_rendered == 0) {
container.innerHTML = `<li class='text-center text-info'>${__("Could not find any plugins for this search query.")}</li>`;
}
dojo.parser.parse(container);
}
},
reload: function() {
const container = dialog.domNode.querySelector(".contents");
container.innerHTML = `<li class='text-center'>${__("Looking for plugins...")}</li>`;
xhr.json("backend.php", {op: "pref-prefs", method: "getAvailablePlugins"}, (reply) => {
dialog.entries = reply;
dialog.render_contents();
});
},
content: `
<div dojoType='fox.Toolbar'>
<div class='pull-right'>
<input name="search" placeholder="${__("Search...")}" type="search" dojoType="dijit.form.TextBox" onkeyup="App.dialogOf(this).search()">
</div>
<div style='height : 16px'>&nbsp;</div> <!-- disgusting -->
</div>
if (plugins_with_updates > 0)
App.find(".update-all-plugins-btn").show();
} else {
Notify.error("Unable to check for plugin updates.");
}
<ul style='clear : both' class="panel panel-scrollable-400px contents plugin-installer-list"> </ul>
<footer>
${App.FormFields.button_tag(App.FormFields.icon("refresh") + " " +__("Refresh"), "", {class: 'alt-primary', onclick: 'App.dialogOf(this).reload()'})}
${App.FormFields.cancel_dialog_tag(__("Close"))}
</footer>
`,
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
dialog.installed_plugins = [...document.querySelectorAll('*[data-plugin-name]')].map((p) => p.getAttribute('data-plugin-name'));
dialog.reload();
});
dialog.show();
},
update: function(name = null) {
const dialog = new fox.SingleUseDialog({
title: __("Plugin Updater"),
title: __("Update plugins"),
need_refresh: false,
plugins_to_update: [],
plugins_to_check: [],
onHide: function() {
if (this.need_refresh) {
Helpers.Prefs.refresh();
@ -364,6 +644,7 @@ const Helpers = {
const container = dialog.domNode.querySelector(".update-results");
console.log('updating', dialog.plugins_to_update);
dialog.attr('title', __('Updating...'));
container.innerHTML = `<li class='text-center'>${__("Updating, please wait...")}</li>`;
let enable_update_btn = false;
@ -376,35 +657,104 @@ const Helpers = {
container.innerHTML = "";
reply.forEach((p) => {
if (p.rv.s == 0)
if (p.rv.git_status == 0)
dialog.need_refresh = true;
else
enable_update_btn = true;
container.innerHTML +=
`
<li><h3 style="margin-top: 0">${p.plugin}</h3>
${p.rv.e ? `<pre class="small text-error">${p.rv.e}</pre>` : ''}
${p.rv.o ? `<pre class="small text-success">${p.rv.o}</pre>` : ''}
<p class="small">
${p.rv.s ? App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", p.rv.s) :
<li>
<h3>${p.plugin}</h3>
${p.rv.stderr ? `<pre class="small text-error pre-wrap">${p.rv.stderr}</pre>` : ''}
${p.rv.stdout ? `<pre class="small text-success pre-wrap">${p.rv.stdout}</pre>` : ''}
<div class="small">
${p.rv.git_status ? App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", p.rv.git_status) :
App.FormFields.icon("check") + " " + __("Update done.")}
</p>
</div>
</li>
`
});
}
dialog.attr('title', __('Updates complete'));
dijit.getEnclosingWidget(dialog.domNode.querySelector(".update-btn")).attr('disabled', !enable_update_btn);
});
},
checkNextPlugin: function() {
const name = dialog.plugins_to_check.shift();
if (name) {
this.checkUpdates(name);
} else {
const num_updated = dialog.plugins_to_update.length;
if (num_updated > 0)
dialog.attr('title',
App.l10n.ngettext('Updates pending for %d plugin', 'Updates pending for %d plugins', num_updated)
.replace("%d", num_updated));
else
dialog.attr('title', __("No updates available"));
dijit.getEnclosingWidget(dialog.domNode.querySelector(".update-btn"))
.attr('disabled', num_updated == 0);
}
},
checkUpdates: function(name) {
console.log('checkUpdates', name);
const container = dialog.domNode.querySelector(".update-results");
dialog.attr('title', __("Checking: %s").replace("%s", name));
//container.innerHTML = `<li class='text-center'>${__("Checking: %s...").replace("%s", name)}</li>`;
xhr.json("backend.php", {op: "pref-prefs", method: "checkForPluginUpdates", name: name}, (reply) => {
if (!reply) {
container.innerHTML += `<li class='text-error'>${__("%s: Operation failed: check event log.").replace("%s", name)}</li>`;
} else {
reply.forEach((p) => {
if (p.rv) {
if (p.rv.need_update) {
dialog.plugins_to_update.push(p.plugin);
const update_button = dijit.getEnclosingWidget(
App.find(`*[data-update-btn-for-plugin="${p.plugin}"]`));
if (update_button)
update_button.domNode.show();
}
if (p.rv.need_update || p.rv.git_status != 0) {
container.innerHTML +=
`
<li><h3>${p.plugin}</h3>
${p.rv.stderr ? `<pre class="small text-error pre-wrap">${p.rv.stderr}</pre>` : ''}
${p.rv.stdout ? `<pre class="small text-success pre-wrap">${p.rv.stdout}</pre>` : ''}
<div class="small">
${p.rv.git_status ? App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", p.rv.git_status) :
App.FormFields.icon("check") + " " + __("Ready to update")}
</div>
</li>
`
}
}
dialog.checkNextPlugin();
});
}
});
},
content: `
<ul class="panel panel-scrollable update-results">
<li class='text-center'>${__("Looking for changes...")}</li>
<ul class="panel panel-scrollable plugin-updater-list update-results">
</ul>
<footer>
${App.FormFields.button_tag(__("Update"), "", {disabled: true, class: "update-btn alt-primary", onclick: "App.dialogOf(this).performUpdate()"})}
${App.FormFields.button_tag(App.FormFields.icon("update") + " " + __("Update"), "", {disabled: true, class: "update-btn alt-primary", onclick: "App.dialogOf(this).performUpdate()"})}
${App.FormFields.cancel_dialog_tag(__("Close"))}
</footer>
`,
@ -413,41 +763,14 @@ const Helpers = {
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhr.json("backend.php", {op: "pref-prefs", method: "checkForPluginUpdates", name: name}, (reply) => {
const container = dialog.domNode.querySelector(".update-results");
let enable_update_btn = false;
if (!reply) {
container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
} else {
container.innerHTML = "";
dialog.plugins_to_update = [];
reply.forEach((p) => {
if (p.rv.s == 0) {
enable_update_btn = true;
dialog.plugins_to_update.push(p.plugin);
}
container.innerHTML +=
`
<li><h3 style="margin-top: 0">${p.plugin}</h3>
${p.rv.e ? `<pre class="small text-error">${p.rv.e}</pre>` : ''}
${p.rv.o ? `<pre class="small text-success">${p.rv.o}</pre>` : ''}
<p class="small">
${p.rv.s ? App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", p.rv.s) :
App.FormFields.icon("check") + " " + __("Ready to update")}
</p>
</li>
`
});
}
dijit.getEnclosingWidget(dialog.domNode.querySelector(".update-btn")).attr('disabled', !enable_update_btn);
});
dialog.plugins_to_update = [];
if (name) {
dialog.checkUpdates(name);
} else {
dialog.plugins_to_check = [...document.querySelectorAll('*[data-plugin-name][data-plugin-local=true]')].map((p) => p.getAttribute('data-plugin-name'));
dialog.checkNextPlugin();
}
});
dialog.show();
@ -506,62 +829,5 @@ const Helpers = {
console.log("export");
window.open("backend.php?op=opml&method=export&" + dojo.formToQuery("opmlExportForm"));
},
publish: function() {
Notify.progress("Loading, please wait...", true);
xhr.json("backend.php", {op: "pref-feeds", method: "getOPMLKey"}, (reply) => {
try {
const dialog = new fox.SingleUseDialog({
title: __("Public OPML URL"),
regenOPMLKey: function() {
if (confirm(__("Replace current OPML publishing address with a new one?"))) {
Notify.progress("Trying to change address...", true);
xhr.json("backend.php", {op: "pref-feeds", method: "regenOPMLKey"}, (reply) => {
if (reply) {
const new_link = reply.link;
const target = this.domNode.querySelector('.generated_url');
if (new_link && target) {
target.href = new_link;
target.innerHTML = new_link;
Notify.close();
} else {
Notify.error("Could not change feed URL.");
}
}
});
}
return false;
},
content: `
<header>${__("Your Public OPML URL is:")}</header>
<section>
<div class='panel text-center'>
<a class='generated_url' href="${App.escapeHtml(reply.link)}" target='_blank'>${App.escapeHtml(reply.link)}</a>
</div>
</section>
<footer class='text-center'>
<button dojoType='dijit.form.Button' onclick="return App.dialogOf(this).regenOPMLKey()">
${__('Generate new URL')}
</button>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
${__('Close this window')}
</button>
</footer>
`
});
dialog.show();
Notify.close();
} catch (e) {
App.Error.report(e);
}
});
},
}
};

@ -69,7 +69,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
const dialog = new fox.SingleUseDialog({
id: "labelEditDlg",
title: __("Edit label"),
style: "width: 650px",
setLabelColor: function (id, fg, bg) {
let kind = '';
@ -121,10 +120,10 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
content: `
<form onsubmit='return false'>
<header>${__("Caption")}</header>
<section>
<input style='font-size : 16px; color : ${fg_color}; background : ${bg_color}; transition : background 0.1s linear'
<input style='font-size : 16px; width : 550px; color : ${fg_color}; background : ${bg_color}; transition : background 0.1s linear'
id='labelEdit_caption'
placeholder="${__("Caption")}"
name='caption'
dojoType='dijit.form.ValidationTextBox'
required='true'
@ -138,7 +137,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
${App.FormFields.hidden_tag('fg_color', fg_color, {}, 'labelEdit_fgColor')}
${App.FormFields.hidden_tag('bg_color', bg_color, {}, 'labelEdit_bgColor')}
<header>${__("Colors")}</header>
<section>
<table width='100%'>
<tr>
@ -168,6 +166,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
<footer>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick='App.dialogOf(this).execute()'>
${App.FormFields.icon("save")}
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>

@ -115,6 +115,7 @@ const Users = {
<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick='App.dialogOf(this).execute()'>
${App.FormFields.icon("save")}
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>

@ -100,7 +100,7 @@ Element.prototype.fadeIn = function(display = undefined){
};
Element.prototype.visible = function() {
return this.style.display != "none" && this.offsetHeight != 0 && this.offsetWidth != 0;
return window.getComputedStyle(this).display != "none"; //&& this.offsetHeight != 0 && this.offsetWidth != 0;
}
Element.visible = function(elem) {
@ -262,8 +262,11 @@ const Lists = {
if (row)
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).querySelectorAll("li").forEach((row) => {
select: function(elem, selected) {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("li").forEach((row) => {
const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]");
if (checkNode) {
const widget = dijit.getEnclosingWidget(checkNode);
@ -278,6 +281,30 @@ const Lists = {
}
});
},
getSelected: function(elem) {
const rv = [];
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("li").forEach((row) => {
if (row.hasClassName("Selected")) {
const rowVal = row.getAttribute("data-row-value");
if (rowVal) {
rv.push(rowVal);
} else {
// either older prefix-XXX notation or separate attribute
const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, "");
if (!isNaN(rowId))
rv.push(parseInt(rowId));
}
}
});
return rv;
}
};
/* exported Tables */
@ -293,8 +320,11 @@ const Tables = {
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).querySelectorAll("tr").forEach((row) => {
select: function(elem, selected) {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("tr").forEach((row) => {
const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]");
if (checkNode) {
const widget = dijit.getEnclosingWidget(checkNode);
@ -309,16 +339,25 @@ const Tables = {
}
});
},
getSelected: function(elemId) {
getSelected: function(elem) {
const rv = [];
$(elemId).querySelectorAll("tr").forEach((row) => {
if (typeof elem == "string")
elem = document.getElementById(elem);
elem.querySelectorAll("tr").forEach((row) => {
if (row.hasClassName("Selected")) {
// either older prefix-XXX notation or separate attribute
const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, "");
const rowVal = row.getAttribute("data-row-value");
if (rowVal) {
rv.push(rowVal);
} else {
// either older prefix-XXX notation or separate attribute
const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, "");
if (!isNaN(rowId))
rv.push(parseInt(rowId));
if (!isNaN(rowId))
rv.push(parseInt(rowId));
}
}
});
@ -393,7 +432,7 @@ const Notify = {
break;
case this.KIND_PROGRESS:
notify.addClassName("notify_progress");
icon = App.getInitParam("icon_indicator_white")
icon = App.getInitParam("icon_oval")
break;
default:
icon = "notifications";

@ -69,15 +69,3 @@ require(["dojo/_base/kernel",
});
});
/* exported hash_get */
function hash_get(key) {
const obj = dojo.queryToObject(window.location.hash.substring(1));
return obj[key];
}
/* exported hash_set */
function hash_set(key, value) {
const obj = dojo.queryToObject(window.location.hash.substring(1));
obj[key] = value;
window.location.hash = dojo.objectToQuery(obj);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1,11 +1,11 @@
parameters:
level: 5
ignoreErrors:
# - '#Constant.*not found#'
- '#Constant.*\b(SUBSTRING_FOR_DATE|SCHEMA_VERSION|SELF_USER_AGENT|LABEL_BASE_INDEX|PLUGIN_FEED_BASE_INDEX)\b.*not found#'
- '#Comparison operation ">" between int<1, max> and 0 is always true.#'
- '#Access to an undefined property DOMNode::\$tagName.#'
- '#Call to an undefined method DOMNode::(get|remove|set)Attribute\(\).#'
- '#Call to an undefined method DOMNode::(get|remove|set|has)Attribute\(\).#'
- '#Call to an undefined method DOMNode::(getElementsByTagName)\(\).#'
- '#PHPDoc tag @param has invalid value#'
- message: '##'
paths:

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save