summaryrefslogtreecommitdiff
path: root/classes/handler
diff options
context:
space:
mode:
authorAndrew Dolgov <[email protected]>2021-03-02 15:16:38 +0300
committerAndrew Dolgov <[email protected]>2021-03-02 15:16:38 +0300
commit9ad4cbeecaed32e4106a7fef30bbe3d14195f78a (patch)
tree687b365d08a17cb2fb737dfc22f78197c62dde1e /classes/handler
parentd6629ed18863f797d34ebdc65815d7af21cb8332 (diff)
wip separate handlersexp-separate-handlers
Diffstat (limited to 'classes/handler')
-rw-r--r--classes/handler/api.php884
-rw-r--r--classes/handler/article.php166
-rw-r--r--classes/handler/feeds.php305
-rw-r--r--classes/handler/opml.php661
-rw-r--r--classes/handler/pluginhandler.php29
-rw-r--r--classes/handler/rpc.php786
6 files changed, 2831 insertions, 0 deletions
diff --git a/classes/handler/api.php b/classes/handler/api.php
new file mode 100644
index 000000000..e860584b4
--- /dev/null
+++ b/classes/handler/api.php
@@ -0,0 +1,884 @@
+<?php
+class Handler_API extends Handler {
+
+ const API_LEVEL = 15;
+
+ const STATUS_OK = 0;
+ const STATUS_ERR = 1;
+
+ const E_API_DISABLED = "API_DISABLED";
+ const E_NOT_LOGGED_IN = "NOT_LOGGED_IN";
+ const E_LOGIN_ERROR = "LOGIN_ERROR";
+ const E_INCORRECT_USAGE = "INCORRECT_USAGE";
+ const E_UNKNOWN_METHOD = "UNKNOWN_METHOD";
+ const E_OPERATION_FAILED = "E_OPERATION_FAILED";
+
+ private $seq;
+
+ private static function _param_to_bool($p) {
+ return $p && ($p !== "f" && $p !== "false");
+ }
+
+ private function _wrap($status, $reply) {
+ print json_encode([
+ "seq" => $this->seq,
+ "status" => $status,
+ "content" => $reply
+ ]);
+ }
+
+ function before($method) {
+ if (parent::before($method)) {
+ header("Content-Type: text/json");
+
+ if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN));
+ return false;
+ }
+
+ if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
+ return false;
+ }
+
+ $this->seq = (int) clean($_REQUEST['seq'] ?? 0);
+
+ return true;
+ }
+ return false;
+ }
+
+ function getVersion() {
+ $rv = array("version" => Config::get_version());
+ $this->_wrap(self::STATUS_OK, $rv);
+ }
+
+ function getApiLevel() {
+ $rv = array("level" => self::API_LEVEL);
+ $this->_wrap(self::STATUS_OK, $rv);
+ }
+
+ function login() {
+
+ if (session_status() == PHP_SESSION_ACTIVE) {
+ session_destroy();
+ }
+
+ session_start();
+
+ $login = clean($_REQUEST["user"]);
+ $password = clean($_REQUEST["password"]);
+
+ if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
+
+ 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)) {
+ $this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
+ "api_level" => self::API_LEVEL));
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
+ }
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
+ }
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
+ return;
+ }
+ }
+
+ function logout() {
+ UserHelper::logout();
+ $this->_wrap(self::STATUS_OK, array("status" => "OK"));
+ }
+
+ function isLoggedIn() {
+ $this->_wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != ''));
+ }
+
+ function getUnread() {
+ $feed_id = clean($_REQUEST["feed_id"]);
+ $is_cat = clean($_REQUEST["is_cat"]);
+
+ if ($feed_id) {
+ $this->_wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
+ } else {
+ $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread()));
+ }
+ }
+
+ /* Method added for ttrss-reader for Android */
+ function getCounters() {
+ $this->_wrap(self::STATUS_OK, Counters::get_all());
+ }
+
+ function getFeeds() {
+ $cat_id = clean($_REQUEST["cat_id"]);
+ $unread_only = self::_param_to_bool(clean($_REQUEST["unread_only"] ?? 0));
+ $limit = (int) clean($_REQUEST["limit"] ?? 0);
+ $offset = (int) clean($_REQUEST["offset"] ?? 0);
+ $include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false));
+
+ $feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
+
+ $this->_wrap(self::STATUS_OK, $feeds);
+ }
+
+ function getCategories() {
+ $unread_only = self::_param_to_bool(clean($_REQUEST["unread_only"] ?? false));
+ $enable_nested = self::_param_to_bool(clean($_REQUEST["enable_nested"] ?? false));
+ $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty'] ?? false));
+
+ // 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";
+
+ $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']]);
+
+ $cats = array();
+
+ while ($line = $sth->fetch()) {
+ if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) {
+ $unread = getFeedUnread($line["id"], true);
+
+ if ($enable_nested)
+ $unread += Feeds::_get_cat_children_unread($line["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"],
+ ));
+ }
+ }
+ }
+
+ foreach (array(-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));
+ }
+ }
+ }
+
+ $this->_wrap(self::STATUS_OK, $cats);
+ }
+
+ function getHeadlines() {
+ $feed_id = clean($_REQUEST["feed_id"]);
+ if ($feed_id !== "") {
+
+ if (is_numeric($feed_id)) $feed_id = (int) $feed_id;
+
+ $limit = (int)clean($_REQUEST["limit"]);
+
+ if (!$limit || $limit >= 200) $limit = 200;
+
+ $offset = (int)clean($_REQUEST["skip"]);
+ $filter = clean($_REQUEST["filter"] ?? "");
+ $is_cat = self::_param_to_bool(clean($_REQUEST["is_cat"] ?? false));
+ $show_excerpt = self::_param_to_bool(clean($_REQUEST["show_excerpt"] ?? false));
+ $show_content = self::_param_to_bool(clean($_REQUEST["show_content"]));
+ /* all_articles, unread, adaptive, marked, updated */
+ $view_mode = clean($_REQUEST["view_mode"] ?? null);
+ $include_attachments = self::_param_to_bool(clean($_REQUEST["include_attachments"] ?? false));
+ $since_id = (int)clean($_REQUEST["since_id"] ?? 0);
+ $include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false));
+ $sanitize_content = !isset($_REQUEST["sanitize"]) ||
+ self::_param_to_bool($_REQUEST["sanitize"]);
+ $force_update = self::_param_to_bool(clean($_REQUEST["force_update"] ?? false));
+ $has_sandbox = self::_param_to_bool(clean($_REQUEST["has_sandbox"] ?? false));
+ $excerpt_length = (int)clean($_REQUEST["excerpt_length"] ?? 0);
+ $check_first_id = (int)clean($_REQUEST["check_first_id"] ?? 0);
+ $include_header = self::_param_to_bool(clean($_REQUEST["include_header"] ?? false));
+
+ $_SESSION['hasSandbox'] = $has_sandbox;
+
+ list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? null));
+
+ /* do not rely on params below */
+
+ $search = clean($_REQUEST["search"] ?? "");
+
+ list($headlines, $headlines_header) = $this->_api_get_headlines($feed_id, $limit, $offset,
+ $filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
+ $include_attachments, $since_id, $search,
+ $include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
+
+ if ($include_header) {
+ $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines));
+ } else {
+ $this->_wrap(self::STATUS_OK, $headlines);
+ }
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
+ }
+ }
+
+ function updateArticle() {
+ $article_ids = explode(",", clean($_REQUEST["article_ids"]));
+ $mode = (int) clean($_REQUEST["mode"]);
+ $data = clean($_REQUEST["data"] ?? "");
+ $field_raw = (int)clean($_REQUEST["field"]);
+
+ $field = "";
+ $set_to = "";
+ $additional_fields = "";
+
+ switch ($field_raw) {
+ case 0:
+ $field = "marked";
+ $additional_fields = ",last_marked = NOW()";
+ break;
+ case 1:
+ $field = "published";
+ $additional_fields = ",last_published = NOW()";
+ break;
+ case 2:
+ $field = "unread";
+ $additional_fields = ",last_read = NOW()";
+ break;
+ case 3:
+ $field = "note";
+ };
+
+ switch ($mode) {
+ case 1:
+ $set_to = "true";
+ break;
+ case 0:
+ $set_to = "false";
+ break;
+ case 2:
+ $set_to = "NOT $field";
+ break;
+ }
+
+ if ($field == "note") $set_to = $this->pdo->quote($data);
+
+ if ($field && $set_to && count($article_ids) > 0) {
+
+ $article_qmarks = arr_qmarks($article_ids);
+
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ $field = $set_to $additional_fields
+ WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
+ $sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
+
+ $num_updated = $sth->rowCount();
+
+ $this->_wrap(self::STATUS_OK, array("status" => "OK",
+ "updated" => $num_updated));
+
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
+ }
+
+ }
+
+ function getArticle() {
+
+ $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"]
+ );
+
+ if ($sanitize_content) {
+ $article["content"] = Sanitizer::sanitize(
+ $line["content"],
+ self::_param_to_bool($line['hide_images']),
+ false, $line["site_url"], false, $line["id"]);
+ } else {
+ $article["content"] = $line["content"];
+ }
+
+ $hook_object = ["article" => &$article];
+
+ PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
+ function ($result) use (&$article) {
+ $article = $result;
+ },
+ $hook_object);
+
+ $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));
+ }
+ }
+
+ function getConfig() {
+ $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");
+
+ $sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM
+ ttrss_feeds WHERE owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+ $row = $sth->fetch();
+
+ $config["num_feeds"] = $row["cf"];
+
+ $this->_wrap(self::STATUS_OK, $config);
+ }
+
+ function updateFeed() {
+ $feed_id = (int) clean($_REQUEST["feed_id"]);
+
+ if (!ini_get("open_basedir")) {
+ RSSUtils::update_rss_feed($feed_id);
+ }
+
+ $this->_wrap(self::STATUS_OK, array("status" => "OK"));
+ }
+
+ function catchupFeed() {
+ $feed_id = clean($_REQUEST["feed_id"]);
+ $is_cat = clean($_REQUEST["is_cat"]);
+ @$mode = clean($_REQUEST["mode"]);
+
+ if (!in_array($mode, ["all", "1day", "1week", "2week"]))
+ $mode = "all";
+
+ Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode);
+
+ $this->_wrap(self::STATUS_OK, array("status" => "OK"));
+ }
+
+ function getPref() {
+ $pref_name = clean($_REQUEST["pref_name"]);
+
+ $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
+ }
+
+ function getLabels() {
+ $article_id = (int)clean($_REQUEST['article_id']);
+
+ $rv = array();
+
+ $sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color
+ FROM ttrss_labels2
+ WHERE owner_uid = ? ORDER BY caption");
+ $sth->execute([$_SESSION['uid']]);
+
+ if ($article_id)
+ $article_labels = Article::_get_labels($article_id);
+ else
+ $article_labels = array();
+
+ while ($line = $sth->fetch()) {
+
+ $checked = false;
+ foreach ($article_labels as $al) {
+ if (Labels::feed_to_label_id($al[0]) == $line['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));
+ }
+
+ $this->_wrap(self::STATUS_OK, $rv);
+ }
+
+ function setArticleLabel() {
+
+ $article_ids = explode(",", clean($_REQUEST["article_ids"]));
+ $label_id = (int) clean($_REQUEST['label_id']);
+ $assign = self::_param_to_bool(clean($_REQUEST['assign']));
+
+ $label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
+
+ $num_updated = 0;
+
+ if ($label) {
+
+ foreach ($article_ids as $id) {
+
+ if ($assign)
+ Labels::add_article($id, $label, $_SESSION["uid"]);
+ else
+ Labels::remove_article($id, $label, $_SESSION["uid"]);
+
+ ++$num_updated;
+
+ }
+ }
+
+ $this->_wrap(self::STATUS_OK, array("status" => "OK",
+ "updated" => $num_updated));
+
+ }
+
+ function index($method) {
+ $plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
+
+ if ($plugin && method_exists($plugin, $method)) {
+ $reply = $plugin->$method();
+
+ $this->_wrap($reply[0], $reply[1]);
+
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method));
+ }
+ }
+
+ function shareToPublished() {
+ $title = strip_tags(clean($_REQUEST["title"]));
+ $url = strip_tags(clean($_REQUEST["url"]));
+ $content = strip_tags(clean($_REQUEST["content"]));
+
+ if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
+ $this->_wrap(self::STATUS_OK, array("status" => 'OK'));
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
+ }
+ }
+
+ private static function _api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
+
+ $feeds = array();
+
+ $pdo = Db::pdo();
+
+ $limit = (int) $limit;
+ $offset = (int) $offset;
+ $cat_id = (int) $cat_id;
+
+ /* Labels */
+
+ /* API only: -4 All feeds, including virtual feeds */
+ if ($cat_id == -4 || $cat_id == -2) {
+ $counters = Counters::get_labels();
+
+ foreach (array_values($counters) as $cv) {
+
+ $unread = $cv["counter"];
+
+ if ($unread || !$unread_only) {
+
+ $row = array(
+ "id" => (int) $cv["id"],
+ "title" => $cv["description"],
+ "unread" => $cv["counter"],
+ "cat_id" => -2,
+ );
+
+ array_push($feeds, $row);
+ }
+ }
+ }
+
+ /* Virtual feeds */
+
+ if ($cat_id == -4 || $cat_id == -1) {
+ foreach (array(-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,
+ );
+ 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']]);
+
+ while ($line = $sth->fetch()) {
+ $unread = getFeedUnread($line["id"], true) +
+ Feeds::_get_cat_children_unread($line["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"]
+ );
+ array_push($feeds, $row);
+ }
+ }
+ }
+
+ /* 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]);
+ }
+
+ while ($line = $sth->fetch()) {
+
+ $unread = getFeedUnread($line["id"]);
+
+ $has_icon = Feeds::_has_icon($line['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"],
+ );
+
+ array_push($feeds, $row);
+ }
+ }
+
+ return $feeds;
+ }
+
+ private static function _api_get_headlines($feed_id, $limit, $offset,
+ $filter, $is_cat, $show_excerpt, $show_content, $view_mode, $order,
+ $include_attachments, $since_id,
+ $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]);
+
+ if ($row = $sth->fetch()) {
+ $last_updated = strtotime($row["last_updated"]);
+ $cache_images = self::_param_to_bool($row["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]);
+ }
+ }
+ }
+
+ $params = array(
+ "feed" => $feed_id,
+ "limit" => $limit,
+ "view_mode" => $view_mode,
+ "cat_view" => $is_cat,
+ "search" => $search,
+ "override_order" => $order,
+ "offset" => $offset,
+ "since_id" => $since_id,
+ "include_children" => $include_nested,
+ "check_first_id" => $check_first_id,
+ "skip_first_id_check" => $skip_first_id_check
+ );
+
+ $qfh_ret = Feeds::_get_headlines($params);
+
+ $result = $qfh_ret[0];
+ $feed_title = $qfh_ret[1];
+ $first_id = $qfh_ret[6];
+
+ $headlines = array();
+
+ $headlines_header = array(
+ 'id' => $feed_id,
+ 'first_id' => $first_id,
+ 'is_cat' => $is_cat);
+
+ if (!is_numeric($result)) {
+ while ($line = $result->fetch()) {
+ $line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
+
+ PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
+ function ($result) use (&$line) {
+ $line = $result;
+ },
+ $line, $excerpt_length);
+
+ $is_updated = ($line["last_read"] == "" &&
+ ($line["unread"] != "t" && $line["unread"] != "1"));
+
+ $tags = explode(",", $line["tag_cache"]);
+
+ $label_cache = $line["label_cache"];
+ $labels = false;
+
+ if ($label_cache) {
+ $label_cache = json_decode($label_cache, true);
+
+ if ($label_cache) {
+ if (($label_cache["no-labels"] ?? 0) == 1)
+ $labels = [];
+ else
+ $labels = $label_cache;
+ }
+ }
+
+ if (!is_array($labels)) $labels = Article::_get_labels($line["id"]);
+
+ $headline_row = array(
+ "id" => (int)$line["id"],
+ "guid" => $line["guid"],
+ "unread" => self::_param_to_bool($line["unread"]),
+ "marked" => self::_param_to_bool($line["marked"]),
+ "published" => self::_param_to_bool($line["published"]),
+ "updated" => (int)strtotime($line["updated"]),
+ "is_updated" => $is_updated,
+ "title" => $line["title"],
+ "link" => $line["link"],
+ "feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
+ "tags" => $tags,
+ );
+
+ $enclosures = Article::_get_enclosures($line['id']);
+
+ if ($include_attachments)
+ $headline_row['attachments'] = $enclosures;
+
+ if ($show_excerpt)
+ $headline_row["excerpt"] = $line["content_preview"];
+
+ if ($show_content) {
+
+ if ($sanitize_content) {
+ $headline_row["content"] = Sanitizer::sanitize(
+ $line["content"],
+ self::_param_to_bool($line['hide_images']),
+ false, $line["site_url"], false, $line["id"]);
+ } else {
+ $headline_row["content"] = $line["content"];
+ }
+ }
+
+ // unify label output to ease parsing
+ if (($labels["no-labels"] ?? 0) == 1) $labels = [];
+
+ $headline_row["labels"] = $labels;
+
+ $headline_row["feed_title"] = isset($line["feed_title"]) ? $line["feed_title"] : $feed_title;
+
+ $headline_row["comments_count"] = (int)$line["num_comments"];
+ $headline_row["comments_link"] = $line["comments"];
+
+ $headline_row["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]);
+
+ $headline_row["author"] = $line["author"];
+
+ $headline_row["score"] = (int)$line["score"];
+ $headline_row["note"] = $line["note"];
+ $headline_row["lang"] = $line["lang"];
+
+ if ($show_content) {
+ $hook_object = ["headline" => &$headline_row];
+
+ list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
+ $line["content"], // unsanitized
+ $line["site_url"]);
+
+ $headline_row["flavor_image"] = $flavor_image;
+ $headline_row["flavor_stream"] = $flavor_stream;
+
+ /* optional */
+ if ($flavor_kind)
+ $headline_row["flavor_kind"] = $flavor_kind;
+
+ PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
+ function ($result) use (&$headline_row) {
+ $headline_row = $result;
+ },
+ $hook_object);
+
+ $headline_row["content"] = DiskCache::rewrite_urls($headline_row['content']);
+ }
+
+ array_push($headlines, $headline_row);
+ }
+ } else if (is_numeric($result) && $result == -1) {
+ $headlines_header['first_id_changed'] = true;
+ }
+
+ return array($headlines, $headlines_header);
+ }
+
+ 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']]);
+
+ if ($row = $sth->fetch()) {
+ Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
+ $this->_wrap(self::STATUS_OK, array("status" => "OK"));
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
+ }
+ }
+
+ function subscribeToFeed() {
+ $feed_url = clean($_REQUEST["feed_url"]);
+ $category_id = (int) clean($_REQUEST["category_id"]);
+ $login = clean($_REQUEST["login"]);
+ $password = clean($_REQUEST["password"]);
+
+ if ($feed_url) {
+ $rc = Feeds::_subscribe($feed_url, $category_id, $login, $password);
+
+ $this->_wrap(self::STATUS_OK, array("status" => $rc));
+ } else {
+ $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
+ }
+ }
+
+ function getFeedTree() {
+ $include_empty = self::_param_to_bool(clean($_REQUEST['include_empty']));
+
+ $pf = new Pref_Feeds($_REQUEST);
+
+ $_REQUEST['mode'] = 2;
+ $_REQUEST['force_show_empty'] = $include_empty;
+
+ $this->_wrap(self::STATUS_OK,
+ array("categories" => $pf->_makefeedtree()));
+ }
+
+ // 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;
+
+ } 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;
+
+ }
+
+ return false;
+ }
+
+
+}
diff --git a/classes/handler/article.php b/classes/handler/article.php
new file mode 100644
index 000000000..269fdf75d
--- /dev/null
+++ b/classes/handler/article.php
@@ -0,0 +1,166 @@
+<?php
+class Handler_Article extends Handler_Protected {
+ function redirect() {
+ $article = ORM::for_table('ttrss_entries')
+ ->table_alias('e')
+ ->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue')
+ ->where('ue.owner_uid', $_SESSION['uid'])
+ ->find_one((int)$_REQUEST['id']);
+
+ if ($article) {
+ $article_url = UrlHelper::validate($article->link);
+
+ if ($article_url) {
+ header("Location: $article_url");
+ return;
+ }
+ }
+
+ header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
+ print "Article not found or has an empty URL.";
+ }
+
+ function printArticleTags() {
+ $id = (int) clean($_REQUEST['id'] ?? 0);
+
+ print json_encode(["id" => $id,
+ "tags" => Article::_get_tags($id)]);
+ }
+
+ function setScore() {
+ $ids = array_map("intval", clean($_REQUEST['ids'] ?? []));
+ $score = (int)clean($_REQUEST['score']);
+
+ $ids_qmarks = arr_qmarks($ids);
+
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+
+ $sth->execute(array_merge([$score], $ids, [$_SESSION['uid']]));
+
+ print json_encode(["id" => $ids, "score" => $score]);
+ }
+
+ function setArticleTags() {
+
+ $id = clean($_REQUEST["id"]);
+
+ //$tags_str = clean($_REQUEST["tags_str"]);
+ //$tags = array_unique(array_map('trim', explode(",", $tags_str)));
+
+ $tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"])));
+
+ $this->pdo->beginTransaction();
+
+ $sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
+ ref_id = ? AND owner_uid = ? LIMIT 1");
+ $sth->execute([$id, $_SESSION['uid']]);
+
+ if ($row = $sth->fetch()) {
+
+ $tags_to_cache = array();
+
+ $int_id = $row['int_id'];
+
+ $dsth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
+ post_int_id = ? AND owner_uid = ?");
+ $dsth->execute([$int_id, $_SESSION['uid']]);
+
+ $csth = $this->pdo->prepare("SELECT post_int_id FROM ttrss_tags
+ WHERE post_int_id = ? AND owner_uid = ? AND tag_name = ?");
+
+ $usth = $this->pdo->prepare("INSERT INTO ttrss_tags
+ (post_int_id, owner_uid, tag_name)
+ VALUES (?, ?, ?)");
+
+ foreach ($tags as $tag) {
+ $csth->execute([$int_id, $_SESSION['uid'], $tag]);
+
+ if (!$csth->fetch()) {
+ $usth->execute([$int_id, $_SESSION['uid'], $tag]);
+ }
+
+ array_push($tags_to_cache, $tag);
+ }
+
+ /* update tag cache */
+
+ $tags_str = join(",", $tags_to_cache);
+
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries
+ SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
+ $sth->execute([$tags_str, $id, $_SESSION['uid']]);
+ }
+
+ $this->pdo->commit();
+
+ // get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ???
+ print json_encode(["id" => (int)$id, "tags" => Article::_get_tags($id)]);
+ }
+
+
+ /*function completeTags() {
+ $search = clean($_REQUEST["search"]);
+
+ $sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
+ WHERE owner_uid = ? AND
+ tag_name LIKE ? ORDER BY tag_name
+ LIMIT 10");
+
+ $sth->execute([$_SESSION['uid'], "$search%"]);
+
+ print "<ul>";
+ while ($line = $sth->fetch()) {
+ print "<li>" . $line["tag_name"] . "</li>";
+ }
+ print "</ul>";
+ }*/
+
+ function assigntolabel() {
+ return $this->_label_ops(true);
+ }
+
+ function removefromlabel() {
+ return $this->_label_ops(false);
+ }
+
+ private function _label_ops($assign) {
+ $reply = array();
+
+ $ids = explode(",", clean($_REQUEST["ids"]));
+ $label_id = clean($_REQUEST["lid"]);
+
+ $label = Labels::find_caption($label_id, $_SESSION["uid"]);
+
+ $reply["labels-for"] = [];
+
+ if ($label) {
+ foreach ($ids as $id) {
+ if ($assign)
+ Labels::add_article($id, $label, $_SESSION["uid"]);
+ else
+ Labels::remove_article($id, $label, $_SESSION["uid"]);
+
+ array_push($reply["labels-for"],
+ ["id" => (int)$id, "labels" => Article::_get_labels($id)]);
+ }
+ }
+
+ $reply["message"] = "UPDATE_COUNTERS";
+
+ print json_encode($reply);
+ }
+
+ function getmetadatabyid() {
+ $article = ORM::for_table('ttrss_entries')
+ ->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
+ ->where('ue.owner_uid', $_SESSION['uid'])
+ ->find_one((int)$_REQUEST['id']);
+
+ if ($article) {
+ echo json_encode(["link" => $article->link, "title" => $article->title]);
+ } else {
+ echo json_encode([]);
+ }
+ }
+}
diff --git a/classes/handler/feeds.php b/classes/handler/feeds.php
new file mode 100644
index 000000000..0d262a554
--- /dev/null
+++ b/classes/handler/feeds.php
@@ -0,0 +1,305 @@
+<?php
+require_once "colors.php";
+
+class Handler_Feeds extends Handler_Protected {
+ function csrf_ignore($method) {
+ $csrf_ignored = array("index");
+
+ return array_search($method, $csrf_ignored) !== false;
+ }
+
+ function catchupAll() {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ last_read = NOW(), unread = false WHERE unread = true AND owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+
+ print json_encode(array("message" => "UPDATE_COUNTERS"));
+ }
+
+ function view() {
+ $reply = array();
+
+ $feed = $_REQUEST["feed"];
+ $method = $_REQUEST["m"] ?? "";
+ $view_mode = $_REQUEST["view_mode"] ?? "";
+ $limit = 30;
+ $cat_view = $_REQUEST["cat"] == "true";
+ $next_unread_feed = $_REQUEST["nuf"] ?? 0;
+ $offset = $_REQUEST["skip"] ?? 0;
+ $order_by = $_REQUEST["order_by"] ?? "";
+ $check_first_id = $_REQUEST["fid"] ?? 0;
+
+ if (is_numeric($feed)) $feed = (int) $feed;
+
+ /* Feed -5 is a special case: it is used to display auxiliary information
+ * when there's nothing to load - e.g. no stuff in fresh feed */
+
+ if ($feed == -5) {
+ print json_encode($this->_generate_dashboard_feed());
+ return;
+ }
+
+ $sth = false;
+ if ($feed < LABEL_BASE_INDEX) {
+
+ $label_feed = Labels::feed_to_label_id($feed);
+
+ $sth = $this->pdo->prepare("SELECT id FROM ttrss_labels2 WHERE
+ id = ? AND owner_uid = ?");
+ $sth->execute([$label_feed, $_SESSION['uid']]);
+
+ } else if (!$cat_view && is_numeric($feed) && $feed > 0) {
+
+ $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
+ id = ? AND owner_uid = ?");
+ $sth->execute([$feed, $_SESSION['uid']]);
+
+ } else if ($cat_view && is_numeric($feed) && $feed > 0) {
+
+ $sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE
+ id = ? AND owner_uid = ?");
+
+ $sth->execute([$feed, $_SESSION['uid']]);
+ }
+
+ if ($sth && !$sth->fetch()) {
+ print json_encode($this->_generate_error_feed(__("Feed not found.")));
+ return;
+ }
+
+ set_pref(Prefs::_DEFAULT_VIEW_MODE, $view_mode);
+ set_pref(Prefs::_DEFAULT_VIEW_ORDER_BY, $order_by);
+
+ /* bump login timestamp if needed */
+ if (time() - $_SESSION["last_login_update"] > 3600) {
+ $user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
+ $user->last_login = Db::NOW();
+ $user->save();
+
+ $_SESSION["last_login_update"] = time();
+ }
+
+ if (!$cat_view && is_numeric($feed) && $feed > 0) {
+ $sth = $this->pdo->prepare("UPDATE ttrss_feeds SET last_viewed = NOW()
+ WHERE id = ? AND owner_uid = ?");
+ $sth->execute([$feed, $_SESSION['uid']]);
+ }
+
+ $reply['headlines'] = [];
+
+ list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query($order_by);
+
+ $ret = Feeds::_format_headlines_list($feed, $method,
+ $view_mode, $limit, $cat_view, $offset,
+ $override_order, true, $check_first_id, $skip_first_id_check, $order_by);
+
+ $headlines_count = $ret[1];
+ $disable_cache = $ret[3];
+ $reply['headlines'] = $ret[4];
+
+ if (!$next_unread_feed)
+ $reply['headlines']['id'] = $feed;
+ else
+ $reply['headlines']['id'] = $next_unread_feed;
+
+ $reply['headlines']['is_cat'] = $cat_view;
+
+ $reply['headlines-info'] = ["count" => (int) $headlines_count,
+ "disable_cache" => (bool) $disable_cache];
+
+ // this is parsed by handleRpcJson() on first viewfeed() to set cdm expanded, etc
+ $reply['runtime-info'] = RPC::_make_runtime_info();
+
+ print json_encode($reply);
+ }
+
+ private function _generate_dashboard_feed() {
+ $reply = array();
+
+ $reply['headlines']['id'] = -5;
+ $reply['headlines']['is_cat'] = false;
+
+ $reply['headlines']['toolbar'] = '';
+
+ $reply['headlines']['content'] = "<div class='whiteBox'>".__('No feed selected.');
+
+ $reply['headlines']['content'] .= "<p><span class=\"text-muted\">";
+
+ $sth = $this->pdo->prepare("SELECT ".SUBSTRING_FOR_DATE."(MAX(last_updated), 1, 19) AS last_updated FROM ttrss_feeds
+ WHERE owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+ $row = $sth->fetch();
+
+ $last_updated = TimeHelper::make_local_datetime($row["last_updated"], false);
+
+ $reply['headlines']['content'] .= sprintf(__("Feeds last updated at %s"), $last_updated);
+
+ $sth = $this->pdo->prepare("SELECT COUNT(id) AS num_errors
+ FROM ttrss_feeds WHERE last_error != '' AND owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+ $row = $sth->fetch();
+
+ $num_errors = $row["num_errors"];
+
+ if ($num_errors > 0) {
+ $reply['headlines']['content'] .= "<br/>";
+ $reply['headlines']['content'] .= "<a class=\"text-muted\" href=\"#\" onclick=\"CommonDialogs.showFeedsWithErrors()\">".
+ __('Some feeds have update errors (click for details)')."</a>";
+ }
+ $reply['headlines']['content'] .= "</span></p>";
+
+ $reply['headlines-info'] = array("count" => 0,
+ "unread" => 0,
+ "disable_cache" => true);
+
+ return $reply;
+ }
+
+ private function _generate_error_feed($error) {
+ $reply = array();
+
+ $reply['headlines']['id'] = -7;
+ $reply['headlines']['is_cat'] = false;
+
+ $reply['headlines']['toolbar'] = '';
+ $reply['headlines']['content'] = "<div class='whiteBox'>". $error . "</div>";
+
+ $reply['headlines-info'] = array("count" => 0,
+ "unread" => 0,
+ "disable_cache" => true);
+
+ return $reply;
+ }
+
+ function subscribeToFeed() {
+ print json_encode([
+ "cat_select" => \Controls\select_feeds_cats("cat")
+ ]);
+ }
+
+ function search() {
+ print json_encode([
+ "show_language" => Config::get(Config::DB_TYPE) == "pgsql",
+ "show_syntax_help" => count(PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEARCH)) == 0,
+ "all_languages" => Pref_Feeds::get_ts_languages(),
+ "default_language" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE)
+ ]);
+ }
+
+ function updatedebugger() {
+ header("Content-type: text/html");
+
+ $xdebug = isset($_REQUEST["xdebug"]) ? (int)$_REQUEST["xdebug"] : 1;
+
+ Debug::set_enabled(true);
+ Debug::set_loglevel($xdebug);
+
+ $feed_id = (int)$_REQUEST["feed_id"];
+ $do_update = ($_REQUEST["action"] ?? "") == "do_update";
+ $csrf_token = $_POST["csrf_token"];
+
+ $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ? AND owner_uid = ?");
+ $sth->execute([$feed_id, $_SESSION['uid']]);
+
+ if (!$sth->fetch()) {
+ print "Access denied.";
+ return;
+ }
+ ?>
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <title>Feed Debugger</title>
+ <style type='text/css'>
+ @media (prefers-color-scheme: dark) {
+ body {
+ background : #222;
+ }
+ }
+ body.css_loading * {
+ display : none;
+ }
+ </style>
+ <script>
+ dojoConfig = {
+ async: true,
+ cacheBust: "<?= get_scripts_timestamp(); ?>",
+ packages: [
+ { name: "fox", location: "../../js" },
+ ]
+ };
+ </script>
+ <?= javascript_tag("js/utility.js") ?>
+ <?= javascript_tag("js/common.js") ?>
+ <?= javascript_tag("lib/dojo/dojo.js") ?>
+ <?= javascript_tag("lib/dojo/tt-rss-layer.js") ?>
+ </head>
+ <body class="flat ttrss_utility feed_debugger css_loading">
+ <script type="text/javascript">
+ require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'fox/form/Select', 'dijit/form/Form',
+ 'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
+ ready(function() {
+ parser.parse();
+ });
+ });
+ </script>
+
+ <div class="container">
+ <h1>Feed Debugger: <?= "$feed_id: " . $this->_get_title($feed_id) ?></h1>
+ <div class="content">
+ <form method="post" action="" dojoType="dijit.form.Form">
+ <?= \Controls\hidden_tag("op", "feeds") ?>
+ <?= \Controls\hidden_tag("method", "updatedebugger") ?>
+ <?= \Controls\hidden_tag("csrf_token", $csrf_token) ?>
+ <?= \Controls\hidden_tag("action", "do_update") ?>
+ <?= \Controls\hidden_tag("feed_id", (string)$feed_id) ?>
+
+ <fieldset>
+ <label>
+ <?= \Controls\select_hash("xdebug", $xdebug,
+ [Debug::$LOG_VERBOSE => "LOG_VERBOSE", Debug::$LOG_EXTENDED => "LOG_EXTENDED"]);
+ ?></label>
+ </fieldset>
+
+ <fieldset>
+ <label class="checkbox"><?= \Controls\checkbox_tag("force_refetch", isset($_REQUEST["force_refetch"])) ?> Force refetch</label>
+ </fieldset>
+
+ <fieldset class="narrow">
+ <label class="checkbox"><?= \Controls\checkbox_tag("force_rehash", isset($_REQUEST["force_rehash"])) ?> Force rehash</label>
+ </fieldset>
+
+ <?= \Controls\submit_tag("Continue") ?>
+ </form>
+
+ <hr>
+
+ <pre><?php
+
+ if ($do_update) {
+ RSSUtils::update_rss_feed($feed_id, true);
+ }
+
+ ?></pre>
+ </div>
+ </div>
+ </body>
+ </html>
+ <?php
+
+ }
+
+ function add() {
+ $feed = clean($_REQUEST['feed']);
+ $cat = clean($_REQUEST['cat'] ?? '');
+ $need_auth = isset($_REQUEST['need_auth']);
+ $login = $need_auth ? clean($_REQUEST['login']) : '';
+ $pass = $need_auth ? clean($_REQUEST['pass']) : '';
+
+ $rc = Feeds::_subscribe($feed, $cat, $login, $pass);
+
+ print json_encode(array("result" => $rc));
+ }
+
+}
+
diff --git a/classes/handler/opml.php b/classes/handler/opml.php
new file mode 100644
index 000000000..a07e8ebff
--- /dev/null
+++ b/classes/handler/opml.php
@@ -0,0 +1,661 @@
+<?php
+class Handler_OPML extends Handler_Protected {
+
+ function csrf_ignore($method) {
+ $csrf_ignored = array("export", "import");
+
+ return array_search($method, $csrf_ignored) !== false;
+ }
+
+ function export() {
+ $output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d"));
+ $include_settings = $_REQUEST["include_settings"] == "1";
+ $owner_uid = $_SESSION["uid"];
+
+ $rc = $this->opml_export($output_name, $owner_uid, false, $include_settings);
+
+ return $rc;
+ }
+
+ function import() {
+ $owner_uid = $_SESSION["uid"];
+
+ header('Content-Type: text/html; charset=utf-8');
+
+ print "<html>
+ <head>
+ ".stylesheet_tag("themes/light.css")."
+ <title>".__("OPML Utility")."</title>
+ <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>
+ </head>
+ <body class='claro ttrss_utility'>
+ <h1>".__('OPML Utility')."</h1><div class='content'>";
+
+ Feeds::_add_cat("Imported feeds", $owner_uid);
+
+ $this->opml_notice(__("Importing OPML..."));
+
+ $this->opml_import($owner_uid);
+
+ print "<br><form method=\"GET\" action=\"prefs.php\">
+ <input type=\"submit\" value=\"".__("Return to preferences")."\">
+ </form>";
+
+ print "</div></body></html>";
+
+
+ }
+
+ // Export
+
+ private function opml_export_category($owner_uid, $cat_id, $hide_private_feeds = false, $include_settings = true) {
+
+ $cat_id = (int) $cat_id;
+
+ if ($hide_private_feeds)
+ $hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')";
+ else
+ $hide_qpart = "true";
+
+ $out = "";
+
+ $ttrss_specific_qpart = "";
+
+ if ($cat_id) {
+ $sth = $this->pdo->prepare("SELECT title,order_id
+ FROM ttrss_feed_categories WHERE id = ?
+ AND owner_uid = ?");
+ $sth->execute([$cat_id, $owner_uid]);
+ $row = $sth->fetch();
+ $cat_title = htmlspecialchars($row['title']);
+
+ if ($include_settings) {
+ $order_id = (int)$row["order_id"];
+ $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\"";
+ }
+ } else {
+ $cat_title = "";
+ }
+
+ if ($cat_title) $out .= "<outline text=\"$cat_title\" $ttrss_specific_qpart>\n";
+
+ $sth = $this->pdo->prepare("SELECT id,title
+ FROM ttrss_feed_categories WHERE
+ (parent_cat = :cat OR (:cat = 0 AND parent_cat IS NULL)) AND
+ owner_uid = :uid ORDER BY order_id, title");
+
+ $sth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
+
+ while ($line = $sth->fetch()) {
+ $out .= $this->opml_export_category($owner_uid, $line["id"], $hide_private_feeds, $include_settings);
+ }
+
+ $fsth = $this->pdo->prepare("select title, feed_url, site_url, update_interval, order_id, purge_interval
+ FROM ttrss_feeds WHERE
+ (cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid AND $hide_qpart
+ ORDER BY order_id, title");
+
+ $fsth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
+
+ while ($fline = $fsth->fetch()) {
+ $title = htmlspecialchars($fline["title"]);
+ $url = htmlspecialchars($fline["feed_url"]);
+ $site_url = htmlspecialchars($fline["site_url"]);
+
+ if ($include_settings) {
+ $update_interval = (int)$fline["update_interval"];
+ $order_id = (int)$fline["order_id"];
+ $purge_interval = (int)$fline["purge_interval"];
+
+ $ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\"";
+ } else {
+ $ttrss_specific_qpart = "";
+ }
+
+ if ($site_url) {
+ $html_url_qpart = "htmlUrl=\"$site_url\"";
+ } else {
+ $html_url_qpart = "";
+ }
+
+ $out .= "<outline type=\"rss\" text=\"$title\" xmlUrl=\"$url\" $ttrss_specific_qpart $html_url_qpart/>\n";
+ }
+
+ if ($cat_title) $out .= "</outline>\n";
+
+ return $out;
+ }
+
+ function opml_export($filename, $owner_uid, $hide_private_feeds = false, $include_settings = true, $file_output = false) {
+ if (!$owner_uid) return;
+
+ if (!$file_output)
+ if (!isset($_REQUEST["debug"])) {
+ header("Content-type: application/xml+opml");
+ header("Content-Disposition: attachment; filename=$filename");
+ } else {
+ header("Content-type: text/xml");
+ }
+
+ $out = "<?xml version=\"1.0\" encoding=\"utf-8\"?".">";
+
+ $out .= "<opml version=\"1.0\">";
+ $out .= "<head>
+ <dateCreated>" . date("r", time()) . "</dateCreated>
+ <title>Tiny Tiny RSS Feed Export</title>
+ </head>";
+ $out .= "<body>";
+
+ $out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings);
+
+ # export tt-rss settings
+
+ if ($include_settings) {
+ $out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Db_Updater::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");
+ $sth->execute([$owner_uid]);
+
+ while ($line = $sth->fetch()) {
+ $name = $line["pref_name"];
+ $value = htmlspecialchars($line["value"]);
+
+ $out .= "<outline pref-name=\"$name\" value=\"$value\"/>";
+ }
+
+ $out .= "</outline>";
+
+ $out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">";
+
+ $sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
+ owner_uid = ?");
+ $sth->execute([$owner_uid]);
+
+ while ($line = $sth->fetch()) {
+ $name = htmlspecialchars($line['caption']);
+ $fg_color = htmlspecialchars($line['fg_color']);
+ $bg_color = htmlspecialchars($line['bg_color']);
+
+ $out .= "<outline label-name=\"$name\" label-fg-color=\"$fg_color\" label-bg-color=\"$bg_color\"/>";
+
+ }
+
+ $out .= "</outline>";
+
+ $out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Db_Updater::SCHEMA_VERSION."\">";
+
+ $sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
+ WHERE owner_uid = ? ORDER BY id");
+ $sth->execute([$owner_uid]);
+
+ while ($line = $sth->fetch()) {
+ $line["rules"] = array();
+ $line["actions"] = array();
+
+ $tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
+ WHERE filter_id = ?");
+ $tmph->execute([$line['id']]);
+
+ while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
+ unset($tmp_line["id"]);
+ unset($tmp_line["filter_id"]);
+
+ $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"]);
+ }
+
+ unset($tmp_line["feed_id"]);
+ unset($tmp_line["cat_id"]);
+
+ array_push($line["rules"], $tmp_line);
+ }
+
+ $tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
+ WHERE filter_id = ?");
+ $tmph->execute([$line['id']]);
+
+ while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
+ unset($tmp_line["id"]);
+ unset($tmp_line["filter_id"]);
+
+ array_push($line["actions"], $tmp_line);
+ }
+
+ unset($line["id"]);
+ unset($line["owner_uid"]);
+ $filter = json_encode($line);
+
+ $out .= "<outline filter-type=\"2\"><![CDATA[$filter]]></outline>";
+
+ }
+
+
+ $out .= "</outline>";
+ }
+
+ $out .= "</body></opml>";
+
+ // Format output.
+ $doc = new DOMDocument();
+ $doc->formatOutput = true;
+ $doc->preserveWhiteSpace = false;
+ $doc->loadXML($out);
+
+ $xpath = new DOMXPath($doc);
+ $outlines = $xpath->query("//outline[@title]");
+
+ // cleanup empty categories
+ foreach ($outlines as $node) {
+ if ($node->getElementsByTagName('outline')->length == 0)
+ $node->parentNode->removeChild($node);
+ }
+
+ $res = $doc->saveXML();
+
+/* // saveXML uses a two-space indent. Change to tabs.
+ $res = preg_replace_callback('/^(?: )+/mu',
+ create_function(
+ '$matches',
+ 'return str_repeat("\t", intval(strlen($matches[0])/2));'),
+ $res); */
+
+ if ($file_output)
+ return file_put_contents($filename, $res) > 0;
+ else
+ print $res;
+ }
+
+ // Import
+
+ private function opml_import_feed($node, $cat_id, $owner_uid) {
+ $attrs = $node->attributes;
+
+ $feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250);
+ if (!$feed_title) $feed_title = mb_substr($attrs->getNamedItem('title')->nodeValue, 0, 250);
+
+ $feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
+ if (!$feed_url) $feed_url = $attrs->getNamedItem('xmlURL')->nodeValue;
+
+ $site_url = mb_substr($attrs->getNamedItem('htmlUrl')->nodeValue, 0, 250);
+
+ if ($feed_url) {
+ $sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
+ feed_url = ? AND owner_uid = ?");
+ $sth->execute([$feed_url, $owner_uid]);
+
+ if (!$feed_title) $feed_title = '[Unknown]';
+
+ 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));
+
+ if (!$cat_id) $cat_id = null;
+
+ $update_interval = (int) $attrs->getNamedItem('ttrssUpdateInterval')->nodeValue;
+ if (!$update_interval) $update_interval = 0;
+
+ $order_id = (int) $attrs->getNamedItem('ttrssSortOrder')->nodeValue;
+ if (!$order_id) $order_id = 0;
+
+ $purge_interval = (int) $attrs->getNamedItem('ttrssPurgeInterval')->nodeValue;
+ if (!$purge_interval) $purge_interval = 0;
+
+ $sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
+ (title, feed_url, owner_uid, cat_id, site_url, order_id, update_interval, purge_interval) VALUES
+ (?, ?, ?, ?, ?, ?, ?, ?)");
+
+ $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));
+ }
+ }
+ }
+
+ private function opml_import_label($node, $owner_uid) {
+ $attrs = $node->attributes;
+ $label_name = $attrs->getNamedItem('label-name')->nodeValue;
+
+ if ($label_name) {
+ $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)));
+ Labels::create($label_name, $fg_color, $bg_color, $owner_uid);
+ } else {
+ $this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)));
+ }
+ }
+ }
+
+ private function opml_import_preference($node) {
+ $attrs = $node->attributes;
+ $pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
+
+ if ($pref_name) {
+ $pref_value = $attrs->getNamedItem('value')->nodeValue;
+
+ $this->opml_notice(T_sprintf("Setting preference key %s to %s",
+ $pref_name, $pref_value));
+
+ set_pref($pref_name, $pref_value);
+ }
+ }
+
+ private function opml_import_filter($node) {
+ $attrs = $node->attributes;
+
+ $filter_type = $attrs->getNamedItem('filter-type')->nodeValue;
+
+ if ($filter_type == '2') {
+ $filter = json_decode($node->nodeValue, true);
+
+ if ($filter) {
+ $match_any_rule = bool_to_sql_bool($filter["match_any_rule"]);
+ $enabled = bool_to_sql_bool($filter["enabled"]);
+ $inverse = bool_to_sql_bool($filter["inverse"]);
+ $title = $filter["title"];
+
+ //print "F: $title, $inverse, $enabled, $match_any_rule";
+
+ $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 = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE
+ owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+
+ $row = $sth->fetch();
+ $filter_id = $row['id'];
+
+ if ($filter_id) {
+ $this->opml_notice(T_sprintf("Adding filter %s...", $title));
+
+ foreach ($filter["rules"] as $rule) {
+ $feed_id = null;
+ $cat_id = null;
+
+ if ($rule["match"]) {
+
+ $match_on = [];
+
+ 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_cat) {
+ $tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
+ WHERE title = ? AND owner_uid = ?");
+
+ $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 = ?");
+ $tsth->execute([$name, $_SESSION['uid']]);
+
+ if ($row = $tsth->fetch()) {
+ $match_id = $row['id'];
+
+ 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);
+
+ $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]);
+
+ } 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 = ?");
+
+ $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"]);
+
+ $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]);
+ }
+ }
+
+ foreach ($filter["actions"] as $action) {
+
+ $action_id = (int)$action["action_id"];
+ $action_param = $action["action_param"];
+
+ $usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions
+ (filter_id,action_id,action_param)
+ VALUES
+ (?, ?, ?)");
+ $usth->execute([$filter_id, $action_id, $action_param]);
+ }
+ }
+ }
+ }
+ }
+
+ private function opml_import_category($doc, $root_node, $owner_uid, $parent_id) {
+ $default_cat_id = (int) $this->get_feed_category('Imported feeds', false);
+
+ if ($root_node) {
+ $cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250);
+
+ if (!$cat_title)
+ $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);
+
+ if ($cat_id === false) {
+ $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);
+ }
+
+ } else {
+ $cat_id = 0;
+ }
+
+ $outlines = $root_node->childNodes;
+
+ } else {
+ $xpath = new DOMXPath($doc);
+ $outlines = $xpath->query("//opml/body/outline");
+
+ $cat_id = 0;
+ $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")));
+
+ foreach ($outlines as $node) {
+ if ($node->hasAttributes() && strtolower($node->tagName) == "outline") {
+ $attrs = $node->attributes;
+ $node_cat_title = $attrs->getNamedItem('text')->nodeValue;
+
+ if (!$node_cat_title)
+ $node_cat_title = $attrs->getNamedItem('title')->nodeValue;
+
+ $node_feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
+
+ if ($node_cat_title && !$node_feed_url) {
+ $this->opml_import_category($doc, $node, $owner_uid, $cat_id);
+ } else {
+
+ if (!$cat_id) {
+ $dst_cat_id = $default_cat_id;
+ } else {
+ $dst_cat_id = $cat_id;
+ }
+
+ switch ($cat_title) {
+ case "tt-rss-prefs":
+ $this->opml_import_preference($node);
+ break;
+ case "tt-rss-labels":
+ $this->opml_import_label($node, $owner_uid);
+ break;
+ case "tt-rss-filters":
+ $this->opml_import_filter($node);
+ break;
+ default:
+ $this->opml_import_feed($node, $dst_cat_id, $owner_uid);
+ }
+ }
+ }
+ }
+ }
+
+ function opml_import($owner_uid) {
+ 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 (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);
+
+ if (!$result) {
+ print_error(__("Unable to move uploaded file."));
+ return;
+ }
+ } else {
+ print_error(__('Error: please upload OPML file.'));
+ return;
+ }
+
+ $loaded = false;
+
+ if (is_file($tmp_file)) {
+ $doc = new DOMDocument();
+ libxml_disable_entity_loader(false);
+ $loaded = $doc->load($tmp_file);
+ libxml_disable_entity_loader(true);
+ unlink($tmp_file);
+ } else if (empty($doc)) {
+ print_error(__('Error: unable to find moved OPML file.'));
+ return;
+ }
+
+ if ($loaded) {
+ $this->pdo->beginTransaction();
+ $this->opml_import_category($doc, false, $owner_uid, false);
+ $this->pdo->commit();
+ } else {
+ print_error(__('Error while parsing document.'));
+ }
+ }
+
+ private function opml_notice($msg) {
+ print "$msg<br/>";
+ }
+
+ static function get_publish_url(){
+ return Config::get_self_url() .
+ "/public.php?op=publishOpml&key=" .
+ Feeds::_get_access_key('OPML:Publish', false, $_SESSION["uid"]);
+ }
+
+ function get_feed_category($feed_cat, $parent_cat_id = false) {
+
+ $parent_cat_id = (int) $parent_cat_id;
+
+ $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']]);
+
+ if ($row = $sth->fetch()) {
+ return $row['id'];
+ } else {
+ return false;
+ }
+ }
+
+
+}
diff --git a/classes/handler/pluginhandler.php b/classes/handler/pluginhandler.php
new file mode 100644
index 000000000..1bb88c149
--- /dev/null
+++ b/classes/handler/pluginhandler.php
@@ -0,0 +1,29 @@
+<?php
+class Handler_PluginHandler extends Handler_Protected {
+ function csrf_ignore($method) {
+ return true;
+ }
+
+ function catchall($method) {
+ $plugin_name = clean($_REQUEST["plugin"]);
+ $plugin = PluginHost::getInstance()->get_plugin($plugin_name);
+ $csrf_token = ($_POST["csrf_token"] ?? "");
+
+ if ($plugin) {
+ if (method_exists($plugin, $method)) {
+ if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) {
+ $plugin->$method();
+ } else {
+ user_error("Rejected ${plugin_name}->${method}(): invalid CSRF token.", E_USER_WARNING);
+ print Errors::to_json(Errors::E_UNAUTHORIZED);
+ }
+ } else {
+ user_error("Rejected ${plugin_name}->${method}(): unknown method.", E_USER_WARNING);
+ print Errors::to_json(Errors::E_UNKNOWN_METHOD);
+ }
+ } else {
+ user_error("Rejected ${plugin_name}->${method}(): unknown plugin.", E_USER_WARNING);
+ print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
+ }
+ }
+}
diff --git a/classes/handler/rpc.php b/classes/handler/rpc.php
new file mode 100644
index 000000000..6fec9f56f
--- /dev/null
+++ b/classes/handler/rpc.php
@@ -0,0 +1,786 @@
+<?php
+class Handler_RPC extends Handler_Protected {
+
+ /*function csrf_ignore($method) {
+ $csrf_ignored = array("completelabels");
+
+ return array_search($method, $csrf_ignored) !== false;
+ }*/
+
+ private function _translations_as_array() {
+
+ global $text_domains;
+
+ $rv = [];
+
+ foreach (array_keys($text_domains) as $domain) {
+
+ /** @var gettext_reader $l10n */
+ $l10n = _get_reader($domain);
+
+ for ($i = 0; $i < $l10n->total; $i++) {
+ if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) {
+ if(strpos($orig, "\000") !== false) { // Plural forms
+ $key = explode(chr(0), $orig);
+
+ $rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
+ $rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural
+ } else {
+ $translation = _dgettext($domain,$orig);
+ $rv[$orig] = $translation;
+ }
+ }
+ }
+ }
+
+ return $rv;
+ }
+
+
+ function togglepref() {
+ $key = clean($_REQUEST["key"]);
+ set_pref($key, !get_pref($key));
+ $value = get_pref($key);
+
+ print json_encode(array("param" =>$key, "value" => $value));
+ }
+
+ function setpref() {
+ // set_pref escapes input, so no need to double escape it here
+ $key = clean($_REQUEST['key']);
+ $value = $_REQUEST['value'];
+
+ set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET');
+
+ print json_encode(array("param" =>$key, "value" => $value));
+ }
+
+ function mark() {
+ $mark = clean($_REQUEST["mark"]);
+ $id = clean($_REQUEST["id"]);
+
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = ?,
+ last_marked = NOW()
+ WHERE ref_id = ? AND owner_uid = ?");
+
+ $sth->execute([$mark, $id, $_SESSION['uid']]);
+
+ print json_encode(array("message" => "UPDATE_COUNTERS"));
+ }
+
+ function delete() {
+ $ids = explode(",", clean($_REQUEST["ids"]));
+ $ids_qmarks = arr_qmarks($ids);
+
+ $sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ $sth->execute(array_merge($ids, [$_SESSION['uid']]));
+
+ Article::_purge_orphans();
+
+ print json_encode(array("message" => "UPDATE_COUNTERS"));
+ }
+
+ function publ() {
+ $pub = clean($_REQUEST["pub"]);
+ $id = clean($_REQUEST["id"]);
+
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ published = ?, last_published = NOW()
+ WHERE ref_id = ? AND owner_uid = ?");
+
+ $sth->execute([$pub, $id, $_SESSION['uid']]);
+
+ print json_encode(array("message" => "UPDATE_COUNTERS"));
+ }
+
+ function getRuntimeInfo() {
+ $reply = [
+ 'runtime-info' => $this->_make_runtime_info()
+ ];
+
+ print json_encode($reply);
+ }
+
+ function getAllCounters() {
+ @$seq = (int) $_REQUEST['seq'];
+
+ $feed_id_count = (int)$_REQUEST["feed_id_count"];
+ $label_id_count = (int)$_REQUEST["label_id_count"];
+
+ // it seems impossible to distinguish empty array [] from a null - both become unset in $_REQUEST
+ // so, count is >= 0 means we had an array, -1 means null
+ // we need null because it means "return all counters"; [] would return nothing
+ if ($feed_id_count == -1)
+ $feed_ids = null;
+ else
+ $feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? []));
+
+ if ($label_id_count == -1)
+ $label_ids = null;
+ else
+ $label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
+
+ $counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ?
+ Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
+
+ $reply = [
+ 'counters' => $counters,
+ 'seq' => $seq
+ ];
+
+ print json_encode($reply);
+ }
+
+ /* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */
+ function catchupSelected() {
+ $ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
+ $cmode = (int)clean($_REQUEST["cmode"]);
+
+ if (count($ids) > 0)
+ Article::_catchup_by_id($ids, $cmode);
+
+ print json_encode(["message" => "UPDATE_COUNTERS",
+ "labels" => Article::_labels_of($ids),
+ "feeds" => Article::_feeds_of($ids)]);
+ }
+
+ function markSelected() {
+ $ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
+ $cmode = (int)clean($_REQUEST["cmode"]);
+
+ if (count($ids) > 0)
+ $this->markArticlesById($ids, $cmode);
+
+ print json_encode(["message" => "UPDATE_COUNTERS",
+ "labels" => Article::_labels_of($ids),
+ "feeds" => Article::_feeds_of($ids)]);
+ }
+
+ function publishSelected() {
+ $ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
+ $cmode = (int)clean($_REQUEST["cmode"]);
+
+ if (count($ids) > 0)
+ $this->publishArticlesById($ids, $cmode);
+
+ print json_encode(["message" => "UPDATE_COUNTERS",
+ "labels" => Article::_labels_of($ids),
+ "feeds" => Article::_feeds_of($ids)]);
+ }
+
+ function sanityCheck() {
+ $_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true";
+ $_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
+
+ $client_location = $_REQUEST["clientLocation"];
+
+ $error = Errors::E_SUCCESS;
+ $error_params = [];
+
+ $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()) {
+ $error = Errors::E_SCHEMA_MISMATCH;
+ } else if ($client_scheme != $server_scheme) {
+ $error = Errors::E_URL_SCHEME_MISMATCH;
+ $error_params["client_scheme"] = $client_scheme;
+ $error_params["server_scheme"] = $server_scheme;
+ $error_params["self_url_path"] = Config::get_self_url();
+ }
+
+ if ($error == Errors::E_SUCCESS) {
+ $reply = [];
+
+ $reply['init-params'] = $this->_make_init_params();
+ $reply['runtime-info'] = $this->_make_runtime_info();
+ $reply['translations'] = $this->_translations_as_array();
+
+ print json_encode($reply);
+ } else {
+ print Errors::to_json($error, $error_params);
+ }
+ }
+
+ /*function completeLabels() {
+ $search = clean($_REQUEST["search"]);
+
+ $sth = $this->pdo->prepare("SELECT DISTINCT caption FROM
+ ttrss_labels2
+ WHERE owner_uid = ? AND
+ LOWER(caption) LIKE LOWER(?) ORDER BY caption
+ LIMIT 5");
+ $sth->execute([$_SESSION['uid'], "%$search%"]);
+
+ print "<ul>";
+ while ($line = $sth->fetch()) {
+ print "<li>" . $line["caption"] . "</li>";
+ }
+ print "</ul>";
+ }*/
+
+ function catchupFeed() {
+ $feed_id = clean($_REQUEST['feed_id']);
+ $is_cat = clean($_REQUEST['is_cat']) == "true";
+ $mode = clean($_REQUEST['mode'] ?? '');
+ $search_query = clean($_REQUEST['search_query']);
+ $search_lang = clean($_REQUEST['search_lang']);
+
+ Feeds::_catchup($feed_id, $is_cat, false, $mode, [$search_query, $search_lang]);
+
+ // return counters here synchronously so that frontend can figure out next unread feed properly
+ print json_encode(['counters' => Counters::get_all()]);
+
+ //print json_encode(array("message" => "UPDATE_COUNTERS"));
+ }
+
+ function setWidescreen() {
+ $wide = (int) clean($_REQUEST["wide"]);
+
+ set_pref(Prefs::WIDESCREEN_MODE, $wide);
+
+ print json_encode(["wide" => $wide]);
+ }
+
+ static function updaterandomfeed_real() {
+
+ $default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL);
+
+ // Test if the feed need a update (update interval exceded).
+ if (Config::get(Config::DB_TYPE) == "pgsql") {
+ $update_limit_qpart = "AND ((
+ update_interval = 0
+ AND (p.value IS NULL OR p.value != '-1')
+ AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL)
+ ) OR (
+ update_interval > 0
+ AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL)
+ ) OR (
+ update_interval >= 0
+ AND (p.value IS NULL OR p.value != '-1')
+ AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
+ ))";
+ } else {
+ $update_limit_qpart = "AND ((
+ update_interval = 0
+ AND (p.value IS NULL OR p.value != '-1')
+ AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE)
+ ) OR (
+ update_interval > 0
+ AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE)
+ ) OR (
+ update_interval >= 0
+ AND (p.value IS NULL OR p.value != '-1')
+ AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
+ ))";
+ }
+
+ // Test if feed is currently being updated by another process.
+ if (Config::get(Config::DB_TYPE) == "pgsql") {
+ $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')";
+ } else {
+ $updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
+ }
+
+ $random_qpart = Db::sql_random_function();
+
+ $pdo = Db::pdo();
+
+ // we could be invoked from public.php with no active session
+ if (!empty($_SESSION["uid"])) {
+ $owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]);
+ } else {
+ $owner_check_qpart = "";
+ }
+
+ $query = "SELECT f.feed_url,f.id
+ FROM
+ ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON
+ (p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL')
+ WHERE
+ f.owner_uid = u.id
+ $owner_check_qpart
+ $update_limit_qpart
+ $updstart_thresh_qpart
+ ORDER BY $random_qpart LIMIT 30";
+
+ $res = $pdo->query($query);
+
+ $num_updated = 0;
+
+ $tstart = time();
+
+ while ($line = $res->fetch()) {
+ $feed_id = $line["id"];
+
+ if (time() - $tstart < ini_get("max_execution_time") * 0.7) {
+ RSSUtils::update_rss_feed($feed_id, true);
+ ++$num_updated;
+ } else {
+ break;
+ }
+ }
+
+ // Purge orphans and cleanup tags
+ Article::_purge_orphans();
+ //cleanup_tags(14, 50000);
+
+ if ($num_updated > 0) {
+ print json_encode(array("message" => "UPDATE_COUNTERS",
+ "num_updated" => $num_updated));
+ } else {
+ print json_encode(array("message" => "NOTHING_TO_UPDATE"));
+ }
+
+ }
+
+ function updaterandomfeed() {
+ self::updaterandomfeed_real();
+ }
+
+ private function markArticlesById($ids, $cmode) {
+
+ $ids_qmarks = arr_qmarks($ids);
+
+ if ($cmode == 0) {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ marked = false, last_marked = NOW()
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ } else if ($cmode == 1) {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ marked = true, last_marked = NOW()
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ } else {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ marked = NOT marked,last_marked = NOW()
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ }
+
+ $sth->execute(array_merge($ids, [$_SESSION['uid']]));
+ }
+
+ private function publishArticlesById($ids, $cmode) {
+
+ $ids_qmarks = arr_qmarks($ids);
+
+ if ($cmode == 0) {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ published = false, last_published = NOW()
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ } else if ($cmode == 1) {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ published = true, last_published = NOW()
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ } else {
+ $sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
+ published = NOT published,last_published = NOW()
+ WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
+ }
+
+ $sth->execute(array_merge($ids, [$_SESSION['uid']]));
+ }
+
+ function log() {
+ $msg = clean($_REQUEST['msg']);
+ $file = basename(clean($_REQUEST['file']));
+ $line = (int) clean($_REQUEST['line']);
+ $context = clean($_REQUEST['context']);
+
+ if ($msg) {
+ Logger::log_error(E_USER_WARNING,
+ $msg, 'client-js:' . $file, $line, $context);
+
+ echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
+ }
+ }
+
+ function checkforupdates() {
+ $rv = ["changeset" => [], "plugins" => []];
+
+ $version = Config::get_version(false);
+
+ $git_timestamp = $version["timestamp"] ?? false;
+ $git_commit = $version["commit"] ?? false;
+
+ if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10 && $git_timestamp) {
+ $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
+
+ if ($content) {
+ $content = json_decode($content, true);
+
+ if ($content && isset($content["changeset"])) {
+ if ($git_timestamp < (int)$content["changeset"]["timestamp"] &&
+ $git_commit != $content["changeset"]["id"]) {
+
+ $rv["changeset"] = $content["changeset"];
+ }
+ }
+ }
+
+ $rv["plugins"] = Pref_Prefs::_get_updated_plugins();
+ }
+
+ print json_encode($rv);
+ }
+
+ private function _make_init_params() {
+ $params = array();
+
+ foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
+ 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) {
+
+ $params[strtolower($param)] = (int) get_pref($param);
+ }
+
+ $params["safe_mode"] = !empty($_SESSION["safe_mode"]);
+ $params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
+ $params["icons_url"] = Config::get(Config::ICONS_URL);
+ $params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
+ $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["is_default_pw"] = UserHelper::is_default_password();
+ $params["label_base_index"] = LABEL_BASE_INDEX;
+
+ $theme = get_pref(Prefs::USER_CSS_THEME);
+ $params["theme"] = theme_exists($theme) ? $theme : "";
+
+ $params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
+
+ $params["php_platform"] = PHP_OS;
+ $params["php_version"] = PHP_VERSION;
+
+ $pdo = Db::pdo();
+
+ $sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
+ ttrss_feeds WHERE owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+ $row = $sth->fetch();
+
+ $max_feed_id = $row["mid"];
+ $num_feeds = $row["nf"];
+
+ $params["self_url_prefix"] = Config::get_self_url();
+ $params["max_feed_id"] = (int) $max_feed_id;
+ $params["num_feeds"] = (int) $num_feeds;
+ $params["hotkeys"] = $this->get_hotkeys_map();
+ $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["labels"] = Labels::get_all($_SESSION["uid"]);
+
+ return $params;
+ }
+
+ private function image_to_base64($filename) {
+ if (file_exists($filename)) {
+ $ext = pathinfo($filename, PATHINFO_EXTENSION);
+
+ return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename));
+ } else {
+ return "";
+ }
+ }
+
+ static function _make_runtime_info() {
+ $data = array();
+
+ $pdo = Db::pdo();
+
+ $sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
+ ttrss_feeds WHERE owner_uid = ?");
+ $sth->execute([$_SESSION['uid']]);
+ $row = $sth->fetch();
+
+ $max_feed_id = $row['mid'];
+ $num_feeds = $row['nf'];
+
+ $data["max_feed_id"] = (int) $max_feed_id;
+ $data["num_feeds"] = (int) $num_feeds;
+ $data['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED);
+ $data["labels"] = Labels::get_all($_SESSION["uid"]);
+
+ if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= 10) {
+ if (Config::get(Config::DB_TYPE) == 'pgsql') {
+ $log_interval = "created_at > NOW() - interval '1 hour'";
+ } else {
+ $log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
+ }
+
+ $sth = $pdo->prepare("SELECT COUNT(id) AS cid
+ FROM ttrss_error_log
+ WHERE
+ errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
+ $log_interval AND
+ errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
+ $sth->execute();
+
+ if ($row = $sth->fetch()) {
+ $data['recent_log_events'] = $row['cid'];
+ }
+ }
+
+ if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) {
+
+ $data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
+
+ if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) {
+
+ $stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp");
+
+ if ($stamp) {
+ $stamp_delta = time() - $stamp;
+
+ if ($stamp_delta > 1800) {
+ $stamp_check = 0;
+ } else {
+ $stamp_check = 1;
+ $_SESSION["daemon_stamp_check"] = time();
+ }
+
+ $data['daemon_stamp_ok'] = $stamp_check;
+
+ $stamp_fmt = date("Y.m.d, G:i", $stamp);
+
+ $data['daemon_stamp'] = $stamp_fmt;
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ static function get_hotkeys_info() {
+ $hotkeys = array(
+ __("Navigation") => array(
+ "next_feed" => __("Open next feed"),
+ "prev_feed" => __("Open previous 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"),
+ "prev_headlines_page" => __("Scroll headlines by one page up"),
+ "next_article_noscroll" => __("Open next article"),
+ "prev_article_noscroll" => __("Open previous article"),
+ "next_article_noexpand" => __("Move to next article (don't expand)"),
+ "prev_article_noexpand" => __("Move to previous article (don't expand)"),
+ "search_dialog" => __("Show search dialog"),
+ "cancel_search" => __("Cancel active search")),
+ __("Article") => array(
+ "toggle_mark" => __("Toggle starred"),
+ "toggle_publ" => __("Toggle published"),
+ "toggle_unread" => __("Toggle unread"),
+ "edit_tags" => __("Edit tags"),
+ "open_in_new_window" => __("Open in new window"),
+ "catchup_below" => __("Mark below as read"),
+ "catchup_above" => __("Mark above as read"),
+ "article_scroll_down" => __("Scroll down"),
+ "article_scroll_up" => __("Scroll up"),
+ "article_page_down" => __("Scroll down page"),
+ "article_page_up" => __("Scroll up page"),
+ "select_article_cursor" => __("Select article under cursor"),
+ "email_article" => __("Email article"),
+ "close_article" => __("Close/collapse article"),
+ "toggle_expand" => __("Toggle article expansion (combined mode)"),
+ "toggle_widescreen" => __("Toggle widescreen mode"),
+ "toggle_full_text" => __("Toggle full article text via Readability")),
+ __("Article selection") => array(
+ "select_all" => __("Select all articles"),
+ "select_unread" => __("Select unread"),
+ "select_marked" => __("Select starred"),
+ "select_published" => __("Select published"),
+ "select_invert" => __("Invert selection"),
+ "select_none" => __("Deselect everything")),
+ __("Feed") => array(
+ "feed_refresh" => __("Refresh current feed"),
+ "feed_unhide_read" => __("Un/hide read feeds"),
+ "feed_subscribe" => __("Subscribe to feed"),
+ "feed_edit" => __("Edit feed"),
+ "feed_catchup" => __("Mark as read"),
+ "feed_reverse" => __("Reverse headlines"),
+ "feed_toggle_vgroup" => __("Toggle headline grouping"),
+ "feed_debug_update" => __("Debug feed update"),
+ "feed_debug_viewfeed" => __("Debug viewfeed()"),
+ "catchup_all" => __("Mark all feeds as read"),
+ "cat_toggle_collapse" => __("Un/collapse current category"),
+ "toggle_cdm_expanded" => __("Toggle auto expand in combined mode"),
+ "toggle_combined_mode" => __("Toggle combined mode")),
+ __("Go to") => array(
+ "goto_all" => __("All articles"),
+ "goto_fresh" => __("Fresh"),
+ "goto_marked" => __("Starred"),
+ "goto_published" => __("Published"),
+ "goto_read" => __("Recently read"),
+ "goto_prefs" => __("Preferences")),
+ __("Other") => array(
+ "create_label" => __("Create label"),
+ "create_filter" => __("Create filter"),
+ "collapse_sidebar" => __("Un/collapse sidebar"),
+ "help_dialog" => __("Show help dialog"))
+ );
+
+ PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO,
+ function ($result) use (&$hotkeys) {
+ $hotkeys = $result;
+ },
+ $hotkeys);
+
+ return $hotkeys;
+ }
+
+ // {3} - 3 panel mode only
+ // {C} - combined mode only
+ static function get_hotkeys_map() {
+ $hotkeys = array(
+ "k" => "next_feed",
+ "j" => "prev_feed",
+ "n" => "next_article_noscroll",
+ "p" => "prev_article_noscroll",
+ "N" => "article_page_down",
+ "P" => "article_page_up",
+ "*(33)|Shift+PgUp" => "article_page_up",
+ "*(34)|Shift+PgDn" => "article_page_down",
+ "{3}(38)|Up" => "prev_article_or_scroll",
+ "{3}(40)|Down" => "next_article_or_scroll",
+ "*(38)|Shift+Up" => "article_scroll_up",
+ "*(40)|Shift+Down" => "article_scroll_down",
+ "^(38)|Ctrl+Up" => "prev_article_noscroll",
+ "^(40)|Ctrl+Down" => "next_article_noscroll",
+ "/" => "search_dialog",
+ "\\" => "cancel_search",
+ "s" => "toggle_mark",
+ "S" => "toggle_publ",
+ "u" => "toggle_unread",
+ "T" => "edit_tags",
+ "o" => "open_in_new_window",
+ "c p" => "catchup_below",
+ "c n" => "catchup_above",
+ "a W" => "toggle_widescreen",
+ "a e" => "toggle_full_text",
+ "e" => "email_article",
+ "a q" => "close_article",
+ "a a" => "select_all",
+ "a u" => "select_unread",
+ "a U" => "select_marked",
+ "a p" => "select_published",
+ "a i" => "select_invert",
+ "a n" => "select_none",
+ "f r" => "feed_refresh",
+ "f a" => "feed_unhide_read",
+ "f s" => "feed_subscribe",
+ "f e" => "feed_edit",
+ "f q" => "feed_catchup",
+ "f x" => "feed_reverse",
+ "f g" => "feed_toggle_vgroup",
+ "f D" => "feed_debug_update",
+ "f G" => "feed_debug_viewfeed",
+ "f C" => "toggle_combined_mode",
+ "f c" => "toggle_cdm_expanded",
+ "Q" => "catchup_all",
+ "x" => "cat_toggle_collapse",
+ "g a" => "goto_all",
+ "g f" => "goto_fresh",
+ "g s" => "goto_marked",
+ "g p" => "goto_published",
+ "g r" => "goto_read",
+ "g P" => "goto_prefs",
+ "r" => "select_article_cursor",
+ "c l" => "create_label",
+ "c f" => "create_filter",
+ "c s" => "collapse_sidebar",
+ "?" => "help_dialog",
+ );
+
+ PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP,
+ function ($result) use (&$hotkeys) {
+ $hotkeys = $result;
+ },
+ $hotkeys);
+
+ $prefixes = array();
+
+ foreach (array_keys($hotkeys) as $hotkey) {
+ $pair = explode(" ", (string)$hotkey, 2);
+
+ if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
+ array_push($prefixes, $pair[0]);
+ }
+ }
+
+ return array($prefixes, $hotkeys);
+ }
+
+ function hotkeyHelp() {
+ $info = self::get_hotkeys_info();
+ $imap = self::get_hotkeys_map();
+ $omap = array();
+
+ foreach ($imap[1] as $sequence => $action) {
+ if (!isset($omap[$action])) $omap[$action] = array();
+
+ array_push($omap[$action], $sequence);
+ }
+
+ ?>
+ <ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>
+ <?php
+
+ foreach ($info as $section => $hotkeys) {
+ ?>
+ <li><h3><?= $section ?></h3></li>
+ <?php
+
+ foreach ($hotkeys as $action => $description) {
+
+ if (!empty($omap[$action])) {
+ foreach ($omap[$action] as $sequence) {
+ if (strpos($sequence, "|") !== false) {
+ $sequence = substr($sequence,
+ strpos($sequence, "|")+1,
+ strlen($sequence));
+ } else {
+ $keys = explode(" ", $sequence);
+
+ for ($i = 0; $i < count($keys); $i++) {
+ if (strlen($keys[$i]) > 1) {
+ $tmp = '';
+ foreach (str_split($keys[$i]) as $c) {
+ switch ($c) {
+ case '*':
+ $tmp .= __('Shift') . '+';
+ break;
+ case '^':
+ $tmp .= __('Ctrl') . '+';
+ break;
+ default:
+ $tmp .= $c;
+ }
+ }
+ $keys[$i] = $tmp;
+ }
+ }
+ $sequence = join(" ", $keys);
+ }
+
+ ?>
+ <li>
+ <div class='hk'><code><?= $sequence ?></code></div>
+ <div class='desc'><?= $description ?></div>
+ </li>
+ <?php
+ }
+ }
+ }
+ }
+ ?>
+ </ul>
+ <footer class='text-center'>
+ <?= \Controls\submit_tag(__('Close this window')) ?>
+ </footer>
+ <?php
+ }
+}