Merge branch 'master' of git.fakecake.org:fox/tt-rss into weblate-integration

master
Andrew Dolgov 4 years ago
commit 93940d2a9f

@ -2,12 +2,12 @@ module.exports = {
"env": {
"browser": true,
"es6": true,
"jquery": true,
"webextensions": true
"jquery": false,
"webextensions": false
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2017
"ecmaVersion": 2018
},
"rules": {
"accessor-pairs": "error",
@ -106,7 +106,7 @@ module.exports = {
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-continue": "off",
"no-console": "off",
"no-console": "off",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "off",
@ -187,7 +187,7 @@ module.exports = {
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-undefined": "off",
"no-undef": "warn",
"no-undef": "warn",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": [
@ -197,7 +197,7 @@ module.exports = {
}
],
"no-unused-expressions": "off",
"no-unused-vars": "warn",
"no-unused-vars": "warn",
"no-use-before-define": "off",
"no-useless-call": "error",
"no-useless-computed-key": "error",

26
.gitignore vendored

@ -1,25 +1,13 @@
Thumbs.db
/.app_is_ready
/deploy.exclude
/deploy.sh
/messages.mo
/node_modules
/locale/**/*.po~
/package-lock.json
*~
*.DS_Store
#*
.idea/*
plugins.local/*
themes.local/*
config.php
feed-icons/*
cache/*/*
lock/*
tags
cache/htmlpurifier/*/*ser
lib/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer/*/*ser
web.config
/.save.cson
/.tags*
/.gutentags
/plugins.local/*
/themes.local/*
/config.php
/feed-icons/*
/cache/*/*
/lock/*
/.vscode/settings.json

@ -0,0 +1,23 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Chrome",
"request": "launch",
"type": "chrome",
"pathMapping": {
"/tt-rss/": "${workspaceFolder}"
},
"urlFilter": "*/tt-rss/*",
"runtimeExecutable": "chrome.exe",
},
{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"pathMappings": {
"/var/www/html/tt-rss": "${workspaceRoot}",
},
"port": 9000
}]
}

@ -1,55 +1,29 @@
<?php
error_reporting(E_ERROR | E_PARSE);
require_once "../config.php";
set_include_path(dirname(__FILE__) . PATH_SEPARATOR .
dirname(dirname(__FILE__)) . PATH_SEPARATOR .
dirname(dirname(__FILE__)) . "/include" . PATH_SEPARATOR .
set_include_path(__DIR__ . PATH_SEPARATOR .
dirname(__DIR__) . PATH_SEPARATOR .
dirname(__DIR__) . "/include" . PATH_SEPARATOR .
get_include_path());
chdir("..");
define('TTRSS_SESSION_NAME', 'ttrss_api_sid');
define('NO_SESSION_AUTOSTART', true);
require_once "autoload.php";
require_once "db.php";
require_once "db-prefs.php";
require_once "functions.php";
require_once "sessions.php";
ini_set('session.use_cookies', 0);
ini_set("session.gc_maxlifetime", 86400);
if (defined('ENABLE_GZIP_OUTPUT') && ENABLE_GZIP_OUTPUT &&
function_exists("ob_gzhandler")) {
ini_set('session.use_cookies', "0");
ini_set("session.gc_maxlifetime", "86400");
ob_start("ob_gzhandler");
} else {
ob_start();
}
ob_start();
$input = file_get_contents("php://input");
if (defined('_API_DEBUG_HTTP_ENABLED') && _API_DEBUG_HTTP_ENABLED) {
// Override $_REQUEST with JSON-encoded data if available
// fallback on HTTP parameters
if ($input) {
$input = json_decode($input, true);
if ($input) $_REQUEST = $input;
}
} else {
// Accept JSON only
$input = json_decode((string)$input, true);
$_REQUEST = $input;
}
$_REQUEST = json_decode((string)file_get_contents("php://input"), true);
if (!empty($_REQUEST["sid"])) {
session_id($_REQUEST["sid"]);
@session_start();
} else if (defined('_API_DEBUG_HTTP_ENABLED')) {
@session_start();
}
startup_gettext();
@ -57,12 +31,14 @@
if (!init_plugins()) return;
if (!empty($_SESSION["uid"])) {
if (!validate_session()) {
if (!\Sessions\validate_session()) {
header("Content-Type: text/json");
print json_encode(array("seq" => -1,
"status" => 1,
"content" => array("error" => "NOT_LOGGED_IN")));
print json_encode([
"seq" => -1,
"status" => API::STATUS_ERR,
"content" => [ "error" => API::E_NOT_LOGGED_IN ]
]);
return;
}

@ -1,5 +1,5 @@
<?php
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
get_include_path());
$op = $_REQUEST["op"];
@ -26,9 +26,9 @@
require_once "autoload.php";
require_once "sessions.php";
require_once "functions.php";
require_once "config.php";
require_once "db.php";
require_once "db-prefs.php";
$op = (string)clean($op);
$method = (string)clean($method);
startup_gettext();
@ -38,18 +38,14 @@
header("Content-Type: text/json; charset=utf-8");
if (ENABLE_GZIP_OUTPUT && function_exists("ob_gzhandler")) {
ob_start("ob_gzhandler");
}
if (SINGLE_USER_MODE) {
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate( "admin", null);
}
if ($_SESSION["uid"]) {
if (!validate_session()) {
if (!empty($_SESSION["uid"])) {
if (!\Sessions\validate_session()) {
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
UserHelper::load_user_plugins($_SESSION["uid"]);
@ -90,12 +86,30 @@
5 => __("Power User"),
10 => __("Administrator"));
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
/* if (strpos($op, PluginHost::PUBLIC_METHOD_DELIMITER) !== false) {
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
// TODO: better implementation that won't modify $_REQUEST
$_REQUEST["plugin"] = $plugin;
$method = $pmethod;
$op = "pluginhandler";
} */
// TODO: figure out if is this still needed
$op = str_replace("-", "_", $op);
$override = PluginHost::getInstance()->lookup_handler($op, $method);
if (class_exists($op) || $override) {
if (strpos($method, "_") === 0) {
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
if ($override) {
$handler = $override;
} else {
@ -114,8 +128,9 @@
if ($reflection->getNumberOfRequiredParameters() == 0) {
$handler->$method();
} else {
user_error("Refusing to invoke method $method of handler $op which has required parameters.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
if (method_exists($handler, "catchall")) {
@ -126,18 +141,19 @@
return;
} else {
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
} else {
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
}
}
header("Content-Type: text/json");
print error_json(13);
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
?>

@ -6,23 +6,38 @@ class API extends Handler {
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;
static function param_to_bool($p) {
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" => 'NOT_LOGGED_IN'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN));
return false;
}
if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref('ENABLE_API_ACCESS')) {
$this->wrap(self::STATUS_ERR, array("error" => 'API_DISABLED'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
return false;
}
@ -33,20 +48,14 @@ class API extends Handler {
return false;
}
function wrap($status, $reply) {
print json_encode(array("seq" => $this->seq,
"status" => $status,
"content" => $reply));
}
function getVersion() {
$rv = array("version" => get_version());
$this->wrap(self::STATUS_OK, $rv);
$this->_wrap(self::STATUS_OK, $rv);
}
function getApiLevel() {
$rv = array("level" => self::API_LEVEL);
$this->wrap(self::STATUS_OK, $rv);
$this->_wrap(self::STATUS_OK, $rv);
}
function login() {
@ -57,36 +66,36 @@ class API extends Handler {
$password = clean($_REQUEST["password"]);
$password_base64 = base64_decode(clean($_REQUEST["password"]));
if (SINGLE_USER_MODE) $login = "admin";
if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
if ($uid = UserHelper::find_user_by_login($login)) {
if (get_pref("ENABLE_API_ACCESS", $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) { // try login with normal password
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
$this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
"api_level" => self::API_LEVEL));
} else if (UserHelper::authenticate($login, $password_base64, false, Auth_Base::AUTH_SERVICE_API)) { // else try with base64_decoded password
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
$this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
"api_level" => self::API_LEVEL));
} else { // else we are not logged in
user_error("Failed login attempt for $login from " . UserHelper::get_user_ip(), E_USER_WARNING);
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
}
} else {
$this->wrap(self::STATUS_ERR, array("error" => "API_DISABLED"));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
}
} else {
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
return;
}
}
function logout() {
Pref_Users::logout_user();
$this->wrap(self::STATUS_OK, array("status" => "OK"));
UserHelper::logout();
$this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
function isLoggedIn() {
$this->wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != ''));
$this->_wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != ''));
}
function getUnread() {
@ -94,33 +103,33 @@ class API extends Handler {
$is_cat = clean($_REQUEST["is_cat"]);
if ($feed_id) {
$this->wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
$this->_wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
} else {
$this->wrap(self::STATUS_OK, array("unread" => Feeds::getGlobalUnread()));
$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::getAllCounters());
$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));
$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));
$include_nested = self::_param_to_bool(clean($_REQUEST["include_nested"] ?? false));
$feeds = $this->api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
$feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
$this->wrap(self::STATUS_OK, $feeds);
$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));
$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
@ -147,7 +156,7 @@ class API extends Handler {
$unread = getFeedUnread($line["id"], true);
if ($enable_nested)
$unread += Feeds::getCategoryChildrenUnread($line["id"]);
$unread += Feeds::_get_cat_children_unread($line["id"]);
if ($unread || !$unread_only) {
array_push($cats, array("id" => (int) $line["id"],
@ -160,18 +169,18 @@ class API extends Handler {
}
foreach (array(-2,-1,0) as $cat_id) {
if ($include_empty || !$this->isCategoryEmpty($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::getCategoryTitle($cat_id),
"title" => Feeds::_get_cat_title($cat_id),
"unread" => (int) $unread));
}
}
}
$this->wrap(self::STATUS_OK, $cats);
$this->_wrap(self::STATUS_OK, $cats);
}
function getHeadlines() {
@ -186,42 +195,42 @@ class API extends Handler {
$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"]));
$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));
$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));
$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));
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));
$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));
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,
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));
$this->_wrap(self::STATUS_OK, array($headlines_header, $headlines));
} else {
$this->wrap(self::STATUS_OK, $headlines);
$this->_wrap(self::STATUS_OK, $headlines);
}
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
@ -277,11 +286,11 @@ class API extends Handler {
$num_updated = $sth->rowCount();
$this->wrap(self::STATUS_OK, array("status" => "OK",
$this->_wrap(self::STATUS_OK, array("status" => "OK",
"updated" => $num_updated));
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
@ -290,9 +299,9 @@ class API extends Handler {
$article_ids = explode(",", clean($_REQUEST["article_id"]));
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
self::param_to_bool($_REQUEST["sanitize"]);
self::_param_to_bool($_REQUEST["sanitize"]);
if ($article_ids) {
if (count($article_ids) > 0) {
$article_qmarks = arr_qmarks($article_ids);
@ -311,22 +320,20 @@ class API extends Handler {
while ($line = $sth->fetch()) {
$attachments = Article::get_article_enclosures($line['id']);
$article = array(
"id" => $line["id"],
"guid" => $line["guid"],
"title" => $line["title"],
"link" => $line["link"],
"labels" => Article::get_article_labels($line['id']),
"unread" => self::param_to_bool($line["unread"]),
"marked" => self::param_to_bool($line["marked"]),
"published" => self::param_to_bool($line["published"]),
"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" => $attachments,
"attachments" => Article::_get_enclosures($line['id']),
"score" => (int)$line["score"],
"feed_title" => $line["feed_title"],
"note" => $line["note"],
@ -336,7 +343,7 @@ class API extends Handler {
if ($sanitize_content) {
$article["content"] = Sanitizer::sanitize(
$line["content"],
self::param_to_bool($line['hide_images']),
self::_param_to_bool($line['hide_images']),
false, $line["site_url"], false, $line["id"]);
} else {
$article["content"] = $line["content"];
@ -350,22 +357,23 @@ class API extends Handler {
},
$hook_object);
$article['content'] = DiskCache::rewriteUrls($article['content']);
$article['content'] = DiskCache::rewrite_urls($article['content']);
array_push($articles, $article);
}
$this->wrap(self::STATUS_OK, $articles);
$this->_wrap(self::STATUS_OK, $articles);
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
function getConfig() {
$config = array(
"icons_dir" => ICONS_DIR,
"icons_url" => ICONS_URL);
$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");
@ -376,7 +384,7 @@ class API extends Handler {
$config["num_feeds"] = $row["cf"];
$this->wrap(self::STATUS_OK, $config);
$this->_wrap(self::STATUS_OK, $config);
}
function updateFeed() {
@ -386,7 +394,7 @@ class API extends Handler {
RSSUtils::update_rss_feed($feed_id);
}
$this->wrap(self::STATUS_OK, array("status" => "OK"));
$this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
function catchupFeed() {
@ -397,15 +405,15 @@ class API extends Handler {
if (!in_array($mode, ["all", "1day", "1week", "2week"]))
$mode = "all";
Feeds::catchup_feed($feed_id, $is_cat, $_SESSION["uid"], $mode);
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode);
$this->wrap(self::STATUS_OK, array("status" => "OK"));
$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)));
$this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
}
function getLabels() {
@ -419,7 +427,7 @@ class API extends Handler {
$sth->execute([$_SESSION['uid']]);
if ($article_id)
$article_labels = Article::get_article_labels($article_id);
$article_labels = Article::_get_labels($article_id);
else
$article_labels = array();
@ -441,14 +449,14 @@ class API extends Handler {
"checked" => $checked));
}
$this->wrap(self::STATUS_OK, $rv);
$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']));
$assign = self::_param_to_bool(clean($_REQUEST['assign']));
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
@ -468,7 +476,7 @@ class API extends Handler {
}
}
$this->wrap(self::STATUS_OK, array("status" => "OK",
$this->_wrap(self::STATUS_OK, array("status" => "OK",
"updated" => $num_updated));
}
@ -479,10 +487,10 @@ class API extends Handler {
if ($plugin && method_exists($plugin, $method)) {
$reply = $plugin->$method();
$this->wrap($reply[0], $reply[1]);
$this->_wrap($reply[0], $reply[1]);
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'UNKNOWN_METHOD', "method" => $method));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method));
}
}
@ -491,14 +499,14 @@ class API extends Handler {
$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'));
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" => 'Publishing failed'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
}
}
static function api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
private static function _api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
$feeds = array();
@ -512,7 +520,7 @@ class API extends Handler {
/* API only: -4 All feeds, including virtual feeds */
if ($cat_id == -4 || $cat_id == -2) {
$counters = Counters::getLabelCounters(true);
$counters = Counters::get_labels();
foreach (array_values($counters) as $cv) {
@ -539,7 +547,7 @@ class API extends Handler {
$unread = getFeedUnread($i);
if ($unread || !$unread_only) {
$title = Feeds::getFeedTitle($i);
$title = Feeds::_get_title($i);
$row = array(
"id" => $i,
@ -564,7 +572,7 @@ class API extends Handler {
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"], true) +
Feeds::getCategoryChildrenUnread($line["id"]);
Feeds::_get_cat_children_unread($line["id"]);
if ($unread || !$unread_only) {
$row = array(
@ -612,7 +620,7 @@ class API extends Handler {
$unread = getFeedUnread($line["id"]);
$has_icon = Feeds::feedHasIcon($line['id']);
$has_icon = Feeds::_has_icon($line['id']);
if ($unread || !$unread_only) {
@ -634,7 +642,7 @@ class API extends Handler {
return $feeds;
}
static function api_get_headlines($feed_id, $limit, $offset,
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,
@ -652,7 +660,7 @@ class API extends Handler {
if ($row = $sth->fetch()) {
$last_updated = strtotime($row["last_updated"]);
$cache_images = self::param_to_bool($row["cache_images"]);
$cache_images = self::_param_to_bool($row["cache_images"]);
if (!$cache_images && time() - $last_updated > 120) {
RSSUtils::update_rss_feed($feed_id, true);
@ -678,7 +686,7 @@ class API extends Handler {
"skip_first_id_check" => $skip_first_id_check
);
$qfh_ret = Feeds::queryFeedHeadlines($params);
$qfh_ret = Feeds::_get_headlines($params);
$result = $qfh_ret[0];
$feed_title = $qfh_ret[1];
@ -720,14 +728,14 @@ class API extends Handler {
}
}
if (!is_array($labels)) $labels = Article::get_article_labels($line["id"]);
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"]),
"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"],
@ -736,7 +744,7 @@ class API extends Handler {
"tags" => $tags,
);
$enclosures = Article::get_article_enclosures($line['id']);
$enclosures = Article::_get_enclosures($line['id']);
if ($include_attachments)
$headline_row['attachments'] = $enclosures;
@ -749,13 +757,11 @@ class API extends Handler {
if ($sanitize_content) {
$headline_row["content"] = Sanitizer::sanitize(
$line["content"],
self::param_to_bool($line['hide_images']),
self::_param_to_bool($line['hide_images']),
false, $line["site_url"], false, $line["id"]);
} else {
$headline_row["content"] = $line["content"];
}
$headline_row["content"] = DiskCache::rewriteUrls($headline_row['content']);
}
// unify label output to ease parsing
@ -768,7 +774,7 @@ class API extends Handler {
$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["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]);
$headline_row["author"] = $line["author"];
@ -776,22 +782,28 @@ class API extends Handler {
$headline_row["note"] = $line["note"];
$headline_row["lang"] = $line["lang"];
$hook_object = ["headline" => &$headline_row];
if ($show_content) {
$hook_object = ["headline" => &$headline_row];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
function ($result) use (&$headline_row) {
$headline_row = $result;
},
$hook_object);
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
$line["content"], // unsanitized
$line["site_url"]);
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::get_article_image($enclosures, $line["content"], $line["site_url"]);
$headline_row["flavor_image"] = $flavor_image;
$headline_row["flavor_stream"] = $flavor_stream;
$headline_row["flavor_image"] = $flavor_image;
$headline_row["flavor_stream"] = $flavor_stream;
/* optional */
if ($flavor_kind)
$headline_row["flavor_kind"] = $flavor_kind;
/* 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);
}
@ -811,9 +823,9 @@ class API extends Handler {
if ($row = $sth->fetch()) {
Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
$this->wrap(self::STATUS_OK, array("status" => "OK"));
$this->_wrap(self::STATUS_OK, array("status" => "OK"));
} else {
$this->wrap(self::STATUS_ERR, array("error" => "FEED_NOT_FOUND"));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
}
}
@ -824,28 +836,28 @@ class API extends Handler {
$password = clean($_REQUEST["password"]);
if ($feed_url) {
$rc = Feeds::subscribe_to_feed($feed_url, $category_id, $login, $password);
$rc = Feeds::_subscribe($feed_url, $category_id, $login, $password);
$this->wrap(self::STATUS_OK, array("status" => $rc));
$this->_wrap(self::STATUS_OK, array("status" => $rc));
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
$this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
function getFeedTree() {
$include_empty = self::param_to_bool(clean($_REQUEST['include_empty']));
$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()));
$this->_wrap(self::STATUS_OK,
array("categories" => $pf->_makefeedtree()));
}
// only works for labels or uncategorized for the time being
private function isCategoryEmpty($id) {
private function _is_cat_empty($id) {
if ($id == -2) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2

@ -5,7 +5,7 @@ class Article extends Handler_Protected {
const ARTICLE_KIND_YOUTUBE = 3;
function redirect() {
$id = clean($_REQUEST['id']);
$id = (int) clean($_REQUEST['id'] ?? 0);
$sth = $this->pdo->prepare("SELECT link FROM ttrss_entries, ttrss_user_entries
WHERE id = ? AND id = ref_id AND owner_uid = ?
@ -13,18 +13,21 @@ class Article extends Handler_Protected {
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$article_url = $row['link'];
$article_url = str_replace("\n", "", $article_url);
$article_url = UrlHelper::validate(str_replace("\n", "", $row['link']));
header("Location: $article_url");
return;
if ($article_url) {
header("Location: $article_url");
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "URL of article $id is blank.";
}
} else {
print_error(__("Article not found."));
}
}
static function create_published_article($title, $url, $content, $labels_str,
static function _create_published_article($title, $url, $content, $labels_str,
$owner_uid) {
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
@ -82,7 +85,7 @@ class Article extends Handler_Protected {
content = ?, content_hash = ? WHERE id = ?");
$sth->execute([$content, $content_hash, $ref_id]);
if (DB_TYPE == "pgsql"){
if (Config::get(Config::DB_TYPE) == "pgsql") {
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
@ -127,7 +130,7 @@ class Article extends Handler_Protected {
if ($row = $sth->fetch()) {
$ref_id = $row["id"];
if (DB_TYPE == "pgsql"){
if (Config::get(Config::DB_TYPE) == "pgsql"){
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
@ -158,39 +161,15 @@ class Article extends Handler_Protected {
return $rc;
}
function editArticleTags() {
$param = clean($_REQUEST['param']);
$tags = self::get_article_tags($param);
$tags_str = join(", ", $tags);
print_hidden("id", "$param");
print_hidden("op", "article");
print_hidden("method", "setArticleTags");
print "<header class='horizontal'>" . __("Tags for this article (separated by commas):")."</header>";
print "<section>";
print "<textarea dojoType='dijit.form.SimpleTextarea' rows='4'
style='height : 100px; font-size : 12px; width : 98%' id='tags_str'
name='tags_str'>$tags_str</textarea>
<div class='autocomplete' id='tags_choices'
style='display:none'></div>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button'
type='submit' class='alt-primary'>".__('Save')."</button> ";
print "<button dojoType='dijit.form.Button'
onclick='App.dialogOf(this).hide()'>".__('Cancel')."</button>";
print "</footer>";
function printArticleTags() {
$id = (int) clean($_REQUEST['id'] ?? 0);
print json_encode(["id" => $id,
"tags" => self::_get_tags($id)]);
}
function setScore() {
$ids = explode(",", clean($_REQUEST['id']));
$ids = array_map("intval", clean($_REQUEST['ids'] ?? []));
$score = (int)clean($_REQUEST['score']);
$ids_qmarks = arr_qmarks($ids);
@ -220,8 +199,10 @@ class Article extends Handler_Protected {
$id = clean($_REQUEST["id"]);
$tags_str = clean($_REQUEST["tags_str"]);
$tags = array_unique(array_map('trim', explode(",", $tags_str)));
//$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();
@ -246,8 +227,6 @@ class Article extends Handler_Protected {
(post_int_id, owner_uid, tag_name)
VALUES (?, ?, ?)");
$tags = FeedItem_Common::normalize_categories($tags);
foreach ($tags as $tag) {
$csth->execute([$int_id, $_SESSION['uid'], $tag]);
@ -269,18 +248,12 @@ class Article extends Handler_Protected {
$this->pdo->commit();
$tags = self::get_article_tags($id);
$tags_str = $this->format_tags_string($tags);
$tags_str_full = join(", ", $tags);
if (!$tags_str_full) $tags_str_full = __("no tags");
print json_encode(array("id" => (int)$id,
"content" => $tags_str, "content_full" => $tags_str_full));
// get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ???
print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]);
}
function completeTags() {
/*function completeTags() {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
@ -295,17 +268,17 @@ class Article extends Handler_Protected {
print "<li>" . $line["tag_name"] . "</li>";
}
print "</ul>";
}
}*/
function assigntolabel() {
return $this->labelops(true);
return $this->_label_ops(true);
}
function removefromlabel() {
return $this->labelops(false);
return $this->_label_ops(false);
}
private function labelops($assign) {
private function _label_ops($assign) {
$reply = array();
$ids = explode(",", clean($_REQUEST["ids"]));
@ -313,22 +286,17 @@ class Article extends Handler_Protected {
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
$reply["info-for-headlines"] = array();
$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"]);
$labels = $this->get_article_labels($id, $_SESSION["uid"]);
array_push($reply["info-for-headlines"],
array("id" => $id, "labels" => $this->format_article_labels($labels)));
array_push($reply["labels-for"],
["id" => (int)$id, "labels" => $this->_get_labels($id)]);
}
}
@ -337,163 +305,84 @@ class Article extends Handler_Protected {
print json_encode($reply);
}
function getArticleFeed($id) {
$sth = $this->pdo->prepare("SELECT feed_id FROM ttrss_user_entries
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
return $row["feed_id"];
} else {
return 0;
}
}
static function format_article_enclosures($id, $always_display_enclosures,
$article_content, $hide_images = false) {
$result = self::get_article_enclosures($id);
$rv = '';
static function _format_enclosures($id,
$always_display_enclosures,
$article_content,
$hide_images = false) {
$enclosures = self::_get_enclosures($id);
$enclosures_formatted = "";
/*foreach ($enclosures as &$enc) {
array_push($enclosures, [
"type" => $enc["content_type"],
"filename" => basename($enc["content_url"]),
"url" => $enc["content_url"],
"title" => $enc["title"],
"width" => (int) $enc["width"],
"height" => (int) $enc["height"]
]);
}*/
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES,
function ($result) use (&$rv) {
function ($result) use (&$enclosures_formatted, &$enclosures) {
if (is_array($result)) {
$rv = $result[0];
$result = $result[1];
$enclosures_formatted = $result[0];
$enclosures = $result[1];
} else {
$rv = $result;
$enclosures_formatted = $result;
}
},
$rv, $result, $id, $always_display_enclosures, $article_content, $hide_images);
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
if ($rv === '' && !empty($result)) {
$entries_html = array();
$entries = array();
$entries_inline = array();
foreach ($result as $line) {
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY,
function($result) use (&$line) {
$line = $result;
},
$line, $id);
$url = $line["content_url"];
$ctype = $line["content_type"];
$title = $line["title"];
$width = $line["width"];
$height = $line["height"];
if (!$ctype) $ctype = __("unknown type");
//$filename = substr($url, strrpos($url, "/")+1);
$filename = basename($url);
$player = format_inline_player($url, $ctype);
if ($player) array_push($entries_inline, $player);
if (!empty($enclosures_formatted)) {
return [
'formatted' => $enclosures_formatted,
'entries' => []
];
}
# $entry .= " <a target=\"_blank\" href=\"" . htmlspecialchars($url) . "\" rel=\"noopener noreferrer\">" .
# $filename . " (" . $ctype . ")" . "</a>";
$rv = [
'formatted' => '',
'entries' => []
];
$entry = "<div onclick=\"Article.popupOpenUrl('".htmlspecialchars($url)."')\"
dojoType=\"dijit.MenuItem\">$filename ($ctype)</div>";
$rv['can_inline'] = isset($_SESSION["uid"]) &&
empty($_SESSION["bw_limit"]) &&
!get_pref("STRIP_IMAGES") &&
($always_display_enclosures || !preg_match("/<img/i", $article_content));
array_push($entries_html, $entry);
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
$entry = array();
foreach ($enclosures as $enc) {
$entry["type"] = $ctype;
$entry["filename"] = $filename;
$entry["url"] = $url;
$entry["title"] = $title;
$entry["width"] = $width;
$entry["height"] = $height;
// this is highly approximate
$enc["filename"] = basename($enc["content_url"]);
array_push($entries, $entry);
}
$rendered_enc = "";
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE,
function ($result) use (&$rendered_enc) {
$rendered_enc = $result;
},
$enc, $id, $rv);
if ($_SESSION['uid'] && !get_pref("STRIP_IMAGES") && !$_SESSION["bw_limit"]) {
if ($always_display_enclosures ||
!preg_match("/<img/i", $article_content)) {
foreach ($entries as $entry) {
$retval = null;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE,
function($result) use (&$retval) {
$retval = $result;
},
$entry, $hide_images);
if (!empty($retval)) {
$rv .= $retval;
} else {
if (preg_match("/image/", $entry["type"])) {
if (!$hide_images) {
$encsize = '';
if ($entry['height'] > 0)
$encsize .= ' height="' . intval($entry['height']) . '"';
if ($entry['width'] > 0)
$encsize .= ' width="' . intval($entry['width']) . '"';
$rv .= "<p><img
alt=\"".htmlspecialchars($entry["filename"])."\"
src=\"" .htmlspecialchars($entry["url"]) . "\"
" . $encsize . " /></p>";
} else {
$rv .= "<p><a target=\"_blank\" rel=\"noopener noreferrer\"
href=\"".htmlspecialchars($entry["url"])."\"
>" .htmlspecialchars($entry["url"]) . "</a></p>";
}
if ($entry['title']) {
$rv.= "<div class=\"enclosure_title\">${entry['title']}</div>";
}
}
}
}
}
}
if ($rendered_enc) {
$rv['formatted'] .= $rendered_enc;
} else {
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY,
function ($result) use (&$enc) {
$enc = $result;
},
$enc, $id, $rv);
if (count($entries_inline) > 0) {
//$rv .= "<hr clear='both'/>";
foreach ($entries_inline as $entry) { $rv .= $entry; };
$rv .= "<br clear='both'/>";
array_push($rv['entries'], $enc);
}
$rv .= "<div class=\"attachments\" dojoType=\"fox.form.DropDownButton\">".
"<span>" . __('Attachments')."</span>";
$rv .= "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
foreach ($entries as $entry) {
if ($entry["title"])
$title = " &mdash; " . truncate_string($entry["title"], 30);
else
$title = "";
if ($entry["filename"])
$filename = truncate_middle(htmlspecialchars($entry["filename"]), 60);
else
$filename = "";
$rv .= "<div onclick='Article.popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")'
dojoType=\"dijit.MenuItem\">".$filename . $title."</div>";
};
$rv .= "</div>";
$rv .= "</div>";
}
return $rv;
}
static function get_article_tags($id, $owner_uid = 0, $tag_cache = false) {
static function _get_tags($id, $owner_uid = 0, $tag_cache = false) {
$a_id = $id;
@ -543,59 +432,22 @@ class Article extends Handler_Protected {
return $tags;
}
static function format_tags_string($tags) {
if (!is_array($tags) || count($tags) == 0) {
return __("no tags");
} else {
$maxtags = min(5, count($tags));
$tags_str = "";
for ($i = 0; $i < $maxtags; $i++) {
$tags_str .= "<a class=\"tag\" href=\"#\" onclick=\"Feeds.open({feed:'".$tags[$i]."'})\">" . $tags[$i] . "</a>, ";
}
$tags_str = mb_substr($tags_str, 0, mb_strlen($tags_str)-2);
if (count($tags) > $maxtags)
$tags_str .= ", &hellip;";
return $tags_str;
}
}
static function format_article_labels($labels) {
if (!is_array($labels)) return '';
$labels_str = "";
foreach ($labels as $l) {
$labels_str .= sprintf("<div class='label'
style='color : %s; background-color : %s'>%s</div>",
$l[2], $l[3], $l[1]);
}
return $labels_str;
function getmetadatabyid() {
$id = clean($_REQUEST['id']);
}
$sth = $this->pdo->prepare("SELECT link, title FROM ttrss_entries, ttrss_user_entries
WHERE ref_id = ? AND ref_id = id AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
static function format_article_note($id, $note, $allow_edit = true) {
if ($row = $sth->fetch()) {
$link = $row['link'];
$title = $row['title'];
if ($allow_edit) {
$onclick = "onclick='Plugins.Note.edit($id)'";
$note_class = 'editable';
} else {
$onclick = '';
$note_class = '';
echo json_encode(["link" => $link, "title" => $title]);
}
return "<div class='article-note $note_class'>
<i class='material-icons'>note</i>
<div $onclick class='body'>$note</div>
</div>";
}
static function get_article_enclosures($id) {
static function _get_enclosures($id) {
$pdo = Db::pdo();
@ -607,10 +459,10 @@ class Article extends Handler_Protected {
$cache = new DiskCache("images");
while ($line = $sth->fetch()) {
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
if ($cache->exists(sha1($line["content_url"]))) {
$line["content_url"] = $cache->getUrl(sha1($line["content_url"]));
$line["content_url"] = $cache->get_url(sha1($line["content_url"]));
}
array_push($rv, $line);
@ -619,11 +471,11 @@ class Article extends Handler_Protected {
return $rv;
}
static function purge_orphans() {
static function _purge_orphans() {
// purge orphaned posts in main content table
if (DB_TYPE == "mysql")
if (Config::get(Config::DB_TYPE) == "mysql")
$limit_qpart = "LIMIT 5000";
else
$limit_qpart = "";
@ -638,7 +490,7 @@ class Article extends Handler_Protected {
}
}
static function catchupArticlesById($ids, $cmode, $owner_uid = false) {
static function _catchup_by_id($ids, $cmode, $owner_uid = false) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -663,21 +515,7 @@ class Article extends Handler_Protected {
$sth->execute(array_merge($ids, [$owner_uid]));
}
static function getLastArticleId() {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT ref_id AS id FROM ttrss_user_entries
WHERE owner_uid = ? ORDER BY ref_id DESC LIMIT 1");
$sth->execute([$_SESSION['uid']]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return -1;
}
}
static function get_article_labels($id, $owner_uid = false) {
static function _get_labels($id, $owner_uid = false) {
$rv = array();
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@ -724,7 +562,7 @@ class Article extends Handler_Protected {
return $rv;
}
static function get_article_image($enclosures, $content, $site_url) {
static function _get_image($enclosures, $content, $site_url) {
$article_image = "";
$article_stream = "";
@ -794,12 +632,59 @@ class Article extends Handler_Protected {
$cache = new DiskCache("images");
if ($article_image && $cache->exists(sha1($article_image)))
$article_image = $cache->getUrl(sha1($article_image));
$article_image = $cache->get_url(sha1($article_image));
if ($article_stream && $cache->exists(sha1($article_stream)))
$article_stream = $cache->getUrl(sha1($article_stream));
$article_stream = $cache->get_url(sha1($article_stream));
return [$article_image, $article_stream, $article_kind];
}
// only cached, returns label ids (not label feed ids)
static function _labels_of(array $article_ids) {
if (count($article_ids) == 0)
return [];
$id_qmarks = arr_qmarks($article_ids);
$sth = Db::pdo()->prepare("SELECT DISTINCT label_cache FROM ttrss_entries e, ttrss_user_entries ue
WHERE ue.ref_id = e.id AND id IN ($id_qmarks)");
$sth->execute($article_ids);
$rv = [];
while ($row = $sth->fetch()) {
$labels = json_decode($row["label_cache"]);
if (isset($labels) && is_array($labels)) {
foreach ($labels as $label) {
if (empty($label["no-labels"]))
array_push($rv, Labels::feed_to_label_id($label[0]));
}
}
}
return array_unique($rv);
}
static function _feeds_of(array $article_ids) {
if (count($article_ids) == 0)
return [];
$id_qmarks = arr_qmarks($article_ids);
$sth = Db::pdo()->prepare("SELECT DISTINCT feed_id FROM ttrss_entries e, ttrss_user_entries ue
WHERE ue.ref_id = e.id AND id IN ($id_qmarks)");
$sth->execute($article_ids);
$rv = [];
while ($row = $sth->fetch()) {
array_push($rv, $row["feed_id"]);
}
return $rv;
}
}

@ -16,7 +16,7 @@ abstract class Auth_Base extends Plugin implements IAuthModule {
// Auto-creates specified user if allowed by system configuration
// Can be used instead of find_user_by_login() by external auth modules
function auto_create_user(string $login, $password = false) {
if ($login && defined('AUTH_AUTO_CREATE') && AUTH_AUTO_CREATE) {
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
$user_id = UserHelper::find_user_by_login($login);
if (!$user_id) {

@ -1,90 +0,0 @@
<?php
class Backend extends Handler_Protected {
/* function digestTest() {
if (isset($_SESSION['uid'])) {
header("Content-type: text/html");
$rv = Digest::prepare_headlines_digest($_SESSION['uid'], 1, 1000);
print "<h1>HTML</h1>";
print $rv[0];
print "<h1>Plain text</h1>";
print "<pre>".$rv[3]."</pre>";
} else {
print error_json(6);
}
} */
function help() {
$topic = basename(clean($_REQUEST["topic"])); // only one for now
if ($topic == "main") {
$info = RPC::get_hotkeys_info();
$imap = RPC::get_hotkeys_map();
$omap = array();
foreach ($imap[1] as $sequence => $action) {
if (!isset($omap[$action])) $omap[$action] = array();
array_push($omap[$action], $sequence);
}
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
$cur_section = "";
foreach ($info as $section => $hotkeys) {
if ($cur_section) print "<li>&nbsp;</li>";
print "<li><h3>" . $section . "</h3></li>";
$cur_section = $section;
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);
}
print "<li>";
print "<div class='hk'><code>$sequence</code></div>";
print "<div class='desc'>$description</div>";
print "</li>";
}
}
}
}
print "</ul>";
}
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>".__('Close this window')."</button>";
print "</footer>";
}
}

@ -0,0 +1,167 @@
<?php
class Config {
private const _ENVVAR_PREFIX = "TTRSS_";
const T_BOOL = 1;
const T_STRING = 2;
const T_INT = 3;
// override defaults, defined below in _DEFAULTS[], via environment: DB_TYPE becomes TTRSS_DB_TYPE, etc
const DB_TYPE = "DB_TYPE";
const DB_HOST = "DB_HOST";
const DB_USER = "DB_USER";
const DB_NAME = "DB_NAME";
const DB_PASS = "DB_PASS";
const DB_PORT = "DB_PORT";
const MYSQL_CHARSET = "MYSQL_CHARSET";
const SELF_URL_PATH = "SELF_URL_PATH";
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE";
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
const LOCK_DIRECTORY = "LOCK_DIRECTORY";
const CACHE_DIR = "CACHE_DIR";
const ICONS_DIR = "ICONS_DIR";
const ICONS_URL = "ICONS_URL";
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN";
const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE";
const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME";
const SMTP_FROM_NAME = "SMTP_FROM_NAME";
const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS";
const DIGEST_SUBJECT = "DIGEST_SUBJECT";
const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES";
const PLUGINS = "PLUGINS";
const LOG_DESTINATION = "LOG_DESTINATION";
const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET";
const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME";
const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS";
const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT";
const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT";
const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT";
const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT";
const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT";
const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT";
const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL";
const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE";
const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE";
const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE";
const CACHE_MAX_DAYS = "CACHE_MAX_DAYS";
const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL";
const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT";
const LOG_SENT_MAIL = "LOG_SENT_MAIL";
const HTTP_PROXY = "HTTP_PROXY";
const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES";
const SESSION_NAME = "SESSION_NAME";
private const _DEFAULTS = [
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
Config::DB_HOST => [ "db", Config::T_STRING ],
Config::DB_USER => [ "", Config::T_STRING ],
Config::DB_NAME => [ "", Config::T_STRING ],
Config::DB_PASS => [ "", Config::T_STRING ],
Config::DB_PORT => [ "5432", Config::T_STRING ],
Config::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ],
Config::SELF_URL_PATH => [ "", Config::T_STRING ],
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ],
Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ],
Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ],
Config::CACHE_DIR => [ "cache", Config::T_STRING ],
Config::ICONS_DIR => [ "feed-icons", Config::T_STRING ],
Config::ICONS_URL => [ "feed-icons", Config::T_STRING ],
Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ],
Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ],
Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ],
Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ],
Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ],
Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ],
Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours",
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::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
Config::T_STRING ],
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_STRING ],
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 ],
Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ],
Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ],
Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ],
Config::DAEMON_FEED_LIMIT => [ 500, Config::T_INT ],
Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ],
Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ],
Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ],
Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ],
Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ],
Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ],
Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ],
Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ],
Config::HTTP_PROXY => [ "", Config::T_STRING ],
Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ],
Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ],
];
private static $instance;
private $params = [];
public static function get_instance() {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
function __construct() {
$ref = new ReflectionClass(get_class($this));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset($this::_DEFAULTS[$const])) {
$override = getenv($this::_ENVVAR_PREFIX . $const);
list ($defval, $deftype) = $this::_DEFAULTS[$const];
$this->params[$cvalue] = [ $this->cast_to(!empty($override) ? $override : $defval, $deftype), $deftype ];
}
}
}
private function cast_to(string $value, int $type_hint) {
switch ($type_hint) {
case self::T_BOOL:
return sql_bool_to_bool($value);
case self::T_INT:
return (int) $value;
default:
return $value;
}
}
private function _get(string $param) {
list ($value, $type_hint) = $this->params[$param];
return $this->cast_to($value, $type_hint);
}
private function _add(string $param, string $default, int $type_hint) {
$override = getenv($this::_ENVVAR_PREFIX . $param);
$this->params[$param] = [ $this->cast_to(!empty($override) ? $override : $default, $type_hint), $type_hint ];
}
static function add(string $param, string $default, int $type_hint = Config::T_STRING) {
$instance = self::get_instance();
return $instance->_add($param, $default, $type_hint);
}
static function get(string $param) {
$instance = self::get_instance();
return $instance->_get($param);
}
}

@ -1,18 +1,27 @@
<?php
class Counters {
static function getAllCounters() {
$data = self::getGlobalCounters();
$data = array_merge($data, self::getVirtCounters());
$data = array_merge($data, self::getLabelCounters());
$data = array_merge($data, self::getFeedCounters());
$data = array_merge($data, self::getCategoryCounters());
static function get_all() {
return array_merge(
self::get_global(),
self::get_virt(),
self::get_labels(),
self::get_feeds(),
self::get_cats()
);
}
return $data;
static function get_conditional(array $feed_ids = null, array $label_ids = null) {
return array_merge(
self::get_global(),
self::get_virt(),
self::get_labels($label_ids),
self::get_feeds($feed_ids),
self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null)
);
}
static private function getCategoryChildrenCounters($cat_id, $owner_uid) {
static private function get_cat_children($cat_id, $owner_uid) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id FROM ttrss_feed_categories WHERE parent_cat = ?
@ -23,52 +32,86 @@ class Counters {
$marked = 0;
while ($line = $sth->fetch()) {
list ($tmp_unread, $tmp_marked) = self::getCategoryChildrenCounters($line["id"], $owner_uid);
list ($tmp_unread, $tmp_marked) = self::get_cat_children($line["id"], $owner_uid);
$unread += $tmp_unread + Feeds::getCategoryUnread($line["id"], $owner_uid);
$marked += $tmp_marked + Feeds::getCategoryMarked($line["id"], $owner_uid);
$unread += $tmp_unread + Feeds::_get_cat_unread($line["id"], $owner_uid);
$marked += $tmp_marked + Feeds::_get_cat_marked($line["id"], $owner_uid);
}
return [$unread, $marked];
}
static function getCategoryCounters() {
private static function get_cats(array $cat_ids = null) {
$ret = [];
/* Labels category */
$cv = array("id" => -2, "kind" => "cat",
"counter" => Feeds::getCategoryUnread(-2));
"counter" => Feeds::_get_cat_unread(-2));
array_push($ret, $cv);
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = :uid
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = :uid");
$sth->execute(["uid" => $_SESSION['uid']]);
if (is_array($cat_ids)) {
if (count($cat_ids) == 0)
return [];
$cat_ids_qmarks = arr_qmarks($cat_ids);
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks)
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = ?");
$sth->execute(array_merge(
[$_SESSION['uid']],
$cat_ids,
[$_SESSION['uid']]
));
} else {
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = :uid
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = :uid");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
if ($line["num_children"] > 0) {
list ($child_counter, $child_marked_counter) = self::getCategoryChildrenCounters($line["id"], $_SESSION["uid"]);
list ($child_counter, $child_marked_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
} else {
$child_counter = 0;
$child_marked_counter = 0;
@ -84,38 +127,53 @@ class Counters {
array_push($ret, $cv);
}
array_push($ret, $cv);
return $ret;
}
static function getFeedCounters($active_feed = false) {
private static function get_feeds(array $feed_ids = null) {
$ret = [];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
GROUP BY f.id");
$sth->execute(["uid" => $_SESSION['uid']]);
if (is_array($feed_ids)) {
if (count($feed_ids) == 0)
return [];
$feed_ids_qmarks = arr_qmarks($feed_ids);
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks)
GROUP BY f.id");
$sth->execute(array_merge([$_SESSION['uid']], $feed_ids));
} else {
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
GROUP BY f.id");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
$id = $line["id"];
$last_error = htmlspecialchars($line["last_error"]);
$last_updated = TimeHelper::make_local_datetime($line['last_updated'], false);
if (Feeds::feedHasIcon($id)) {
$has_img = filemtime(Feeds::getIconFile($id));
if (Feeds::_has_icon($id)) {
$has_img = filemtime(Feeds::_get_icon_file($id));
} else {
$has_img = false;
}
@ -132,11 +190,8 @@ class Counters {
"has_img" => (int) $has_img
];
if ($last_error)
$cv["error"] = $last_error;
if ($active_feed && $id == $active_feed)
$cv["title"] = truncate_string($line["title"], 30);
$cv["error"] = $line["last_error"];
$cv["title"] = truncate_string($line["title"], 30);
array_push($ret, $cv);
@ -145,11 +200,11 @@ class Counters {
return $ret;
}
static function getGlobalCounters($global_unread = -1) {
private static function get_global($global_unread = -1) {
$ret = [];
if ($global_unread == -1) {
$global_unread = Feeds::getGlobalUnread();
$global_unread = Feeds::_get_global_unread();
}
$cv = [
@ -178,7 +233,7 @@ class Counters {
return $ret;
}
static function getVirtCounters() {
private static function get_virt() {
$ret = [];
@ -187,7 +242,7 @@ class Counters {
$count = getFeedUnread($i);
if ($i == 0 || $i == -1 || $i == -2)
$auxctr = Feeds::getFeedArticles($i, false);
$auxctr = Feeds::_get_counters($i, false);
else
$auxctr = 0;
@ -222,23 +277,42 @@ class Counters {
return $ret;
}
static function getLabelCounters($descriptions = false) {
static function get_labels(array $label_ids = null) {
$ret = [];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
WHERE ttrss_labels2.owner_uid = :uid
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute([":uid" => $_SESSION['uid']]);
if (is_array($label_ids)) {
if (count($label_ids) == 0)
return [];
$label_ids_qmarks = arr_qmarks($label_ids);
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ?
WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks)
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute(array_merge([$_SESSION["uid"], $_SESSION["uid"]], $label_ids));
} else {
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
WHERE ttrss_labels2.owner_uid = :uid
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute([":uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
@ -248,12 +322,10 @@ class Counters {
"id" => $id,
"counter" => (int) $line["count_unread"],
"auxcounter" => (int) $line["total"],
"markedcounter" => (int) $line["count_marked"]
"markedcounter" => (int) $line["count_marked"],
"description" => $line["caption"]
];
if ($descriptions)
$cv["description"] = $line["caption"];
array_push($ret, $cv);
}

@ -1,13 +1,9 @@
<?php
class Db
{
/* @var Db $instance */
private static $instance;
/* @var IDb $adapter */
private $adapter;
private $link;
/* @var PDO $pdo */
@ -17,49 +13,17 @@ class Db
//
}
private function legacy_connect() {
user_error("Legacy connect requested to " . DB_TYPE, E_USER_NOTICE);
$er = error_reporting(E_ALL);
switch (DB_TYPE) {
case "mysql":
$this->adapter = new Db_Mysqli();
break;
case "pgsql":
$this->adapter = new Db_Pgsql();
break;
default:
die("Unknown DB_TYPE: " . DB_TYPE);
}
if (!$this->adapter) {
print("Error initializing database adapter for " . DB_TYPE);
exit(100);
}
$this->link = $this->adapter->connect(DB_HOST, DB_USER, DB_PASS, DB_NAME, defined('DB_PORT') ? DB_PORT : "");
if (!$this->link) {
print("Error connecting through adapter: " . $this->adapter->last_error());
exit(101);
}
error_reporting($er);
}
// this really shouldn't be used unless a separate PDO connection is needed
// normal usage is Db::pdo()->prepare(...) etc
public function pdo_connect() {
$db_port = defined('DB_PORT') && DB_PORT ? ';port=' . DB_PORT : '';
$db_host = defined('DB_HOST') && DB_HOST ? ';host=' . DB_HOST : '';
$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) : '';
try {
$pdo = new PDO(DB_TYPE . ':dbname=' . DB_NAME . $db_host . $db_port,
DB_USER,
DB_PASS);
$pdo = new PDO(Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port,
Config::get(Config::DB_USER),
Config::get(Config::DB_PASS));
} catch (Exception $e) {
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
exit(101);
@ -67,18 +31,18 @@ class Db
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$pdo->query("set client_encoding = 'UTF-8'");
$pdo->query("set datestyle = 'ISO, european'");
$pdo->query("set TIME ZONE 0");
$pdo->query("set cpu_tuple_cost = 0.5");
} else if (DB_TYPE == "mysql") {
} else if (Config::get(Config::DB_TYPE) == "mysql") {
$pdo->query("SET time_zone = '+0:0'");
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
$pdo->query("SET NAMES " . MYSQL_CHARSET);
if (Config::get(Config::MYSQL_CHARSET)) {
$pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET));
}
}
@ -92,17 +56,6 @@ class Db
return self::$instance;
}
public static function get() : Db {
if (self::$instance == null)
self::$instance = new self();
if (!self::$instance->adapter) {
self::$instance->legacy_connect();
}
return self::$instance->adapter;
}
public static function pdo() : PDO {
if (self::$instance == null)
self::$instance = new self();
@ -115,7 +68,7 @@ class Db
}
public static function sql_random_function() {
if (DB_TYPE == "mysql") {
if (Config::get(Config::DB_TYPE) == "mysql") {
return "RAND()";
} else {
return "RANDOM()";

@ -1,85 +0,0 @@
<?php
class Db_Mysqli implements IDb {
private $link;
private $last_error;
function connect($host, $user, $pass, $db, $port) {
if ($port)
$this->link = mysqli_connect($host, $user, $pass, $db, $port);
else
$this->link = mysqli_connect($host, $user, $pass, $db);
if ($this->link) {
$this->init();
return $this->link;
} else {
print("Unable to connect to database (as $user to $host, database $db): " . mysqli_connect_error());
exit(102);
}
}
function escape_string($s, $strip_tags = true) {
if ($strip_tags) $s = strip_tags($s);
return mysqli_real_escape_string($this->link, $s);
}
function query($query, $die_on_error = true) {
$result = @mysqli_query($this->link, $query);
if (!$result) {
$this->last_error = @mysqli_error($this->link);
@mysqli_query($this->link, "ROLLBACK");
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
}
return $result;
}
function fetch_assoc($result) {
return mysqli_fetch_assoc($result);
}
function num_rows($result) {
return mysqli_num_rows($result);
}
function fetch_result($result, $row, $param) {
if (mysqli_data_seek($result, $row)) {
$line = mysqli_fetch_assoc($result);
return $line[$param];
} else {
return false;
}
}
function close() {
return mysqli_close($this->link);
}
function affected_rows($result) {
return mysqli_affected_rows($this->link);
}
function last_error() {
return mysqli_error($this->link);
}
function last_query_error() {
return $this->last_error;
}
function init() {
$this->query("SET time_zone = '+0:0'");
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
mysqli_set_charset($this->link, MYSQL_CHARSET);
}
return true;
}
}

@ -1,91 +0,0 @@
<?php
class Db_Pgsql implements IDb {
private $link;
private $last_error;
function connect($host, $user, $pass, $db, $port) {
$string = "dbname=$db user=$user";
if ($pass) {
$string .= " password=$pass";
}
if ($host) {
$string .= " host=$host";
}
if (is_numeric($port) && $port > 0) {
$string = "$string port=" . $port;
}
$this->link = pg_connect($string);
if (!$this->link) {
print("Unable to connect to database (as $user to $host, database $db):" . pg_last_error());
exit(102);
}
$this->init();
return $this->link;
}
function escape_string($s, $strip_tags = true) {
if ($strip_tags) $s = strip_tags($s);
return pg_escape_string($s);
}
function query($query, $die_on_error = true) {
$result = @pg_query($this->link, $query);
if (!$result) {
$this->last_error = @pg_last_error($this->link);
@pg_query($this->link, "ROLLBACK");
$query = htmlspecialchars($query); // just in case
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
}
return $result;
}
function fetch_assoc($result) {
return pg_fetch_assoc($result);
}
function num_rows($result) {
return pg_num_rows($result);
}
function fetch_result($result, $row, $param) {
return pg_fetch_result($result, $row, $param);
}
function close() {
return pg_close($this->link);
}
function affected_rows($result) {
return pg_affected_rows($result);
}
function last_error() {
return pg_last_error($this->link);
}
function last_query_error() {
return $this->last_error;
}
function init() {
$this->query("set client_encoding = 'UTF-8'");
pg_set_client_encoding("UNICODE");
$this->query("set datestyle = 'ISO, european'");
$this->query("set TIME ZONE 0");
$this->query("set cpu_tuple_cost = 0.5");
return true;
}
}

@ -6,9 +6,8 @@ class Db_Prefs {
function __construct() {
$this->pdo = Db::pdo();
$this->cache = array();
if (!empty($_SESSION["uid"])) $this->cache();
$this->cache = [];
$this->cache_prefs();
}
private function __clone() {
@ -22,31 +21,30 @@ class Db_Prefs {
return self::$instance;
}
function cache() {
$user_id = $_SESSION["uid"];
$profile = $_SESSION["profile"] ?? false;
private function cache_prefs() {
if (!empty($_SESSION["uid"])) {
$profile = $_SESSION["profile"] ?? false;
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
$sth = $this->pdo->prepare("SELECT
value,ttrss_prefs_types.type_name as type_name,ttrss_prefs.pref_name AS pref_name
FROM
ttrss_user_prefs,ttrss_prefs,ttrss_prefs_types
WHERE
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
ttrss_prefs.pref_name NOT LIKE '_MOBILE%' AND
ttrss_prefs_types.id = type_id AND
owner_uid = :uid AND
ttrss_user_prefs.pref_name = ttrss_prefs.pref_name");
$sth = $this->pdo->prepare("SELECT up.pref_name, pt.type_name, up.value
FROM ttrss_user_prefs up
JOIN ttrss_prefs p ON (up.pref_name = p.pref_name)
JOIN ttrss_prefs_types pt ON (p.type_id = pt.id)
WHERE
up.pref_name NOT LIKE '_MOBILE%' AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
owner_uid = :uid");
$sth->execute([":profile" => $profile, ":uid" => $user_id]);
$sth->execute([":profile" => $profile, ":uid" => $_SESSION["uid"]]);
while ($line = $sth->fetch()) {
if ($user_id == $_SESSION["uid"]) {
$pref_name = $line["pref_name"];
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$pref_name = $row["pref_name"];
$this->cache[$pref_name]["type"] = $line["type_name"];
$this->cache[$pref_name]["value"] = $line["value"];
$this->cache[$pref_name] = [
"type" => $row["type_name"],
"value" => $row["value"]
];
}
}
}
@ -67,35 +65,37 @@ class Db_Prefs {
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
$sth = $this->pdo->prepare("SELECT
value,ttrss_prefs_types.type_name as type_name
FROM
ttrss_user_prefs,ttrss_prefs,ttrss_prefs_types
$sth = $this->pdo->prepare("SELECT up.pref_name, pt.type_name, up.value
FROM ttrss_user_prefs up
JOIN ttrss_prefs p ON (up.pref_name = p.pref_name)
JOIN ttrss_prefs_types pt ON (p.type_id = pt.id)
WHERE
up.pref_name = :pref_name AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
ttrss_user_prefs.pref_name = :pref_name AND
ttrss_prefs_types.id = type_id AND
owner_uid = :uid AND
ttrss_user_prefs.pref_name = ttrss_prefs.pref_name");
owner_uid = :uid");
$sth->execute([":uid" => $user_id, ":profile" => $profile, ":pref_name" => $pref_name]);
if ($row = $sth->fetch()) {
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$value = $row["value"];
$type_name = $row["type_name"];
if ($user_id == ($_SESSION["uid"] ?? false)) {
$this->cache[$pref_name]["type"] = $type_name;
$this->cache[$pref_name]["value"] = $value;
$this->cache[$pref_name] = [
"type" => $row["type_name"],
"value" => $row["value"]
];
}
return $this->convert($value, $type_name);
} else if ($die_on_error) {
user_error("Fatal error, unknown preferences key: $pref_name (owner: $user_id)", E_USER_ERROR);
return null;
user_error("Failed retrieving preference $pref_name for user $user_id", E_USER_ERROR);
} else {
return null;
user_error("Failed retrieving preference $pref_name for user $user_id", E_USER_WARNING);
}
return null;
}
function convert($value, $type_name) {

@ -11,16 +11,16 @@ class DbUpdater {
$this->need_version = (int) $need_version;
}
function getSchemaVersion() {
function get_schema_version() {
$row = $this->pdo->query("SELECT schema_version FROM ttrss_version")->fetch();
return (int) $row['schema_version'];
}
function isUpdateRequired() {
return $this->getSchemaVersion() < $this->need_version;
function is_update_required() {
return $this->get_schema_version() < $this->need_version;
}
function getSchemaLines($version) {
function get_schema_lines($version) {
$filename = "schema/versions/".$this->db_type."/$version.sql";
if (file_exists($filename)) {
@ -31,10 +31,10 @@ class DbUpdater {
}
}
function performUpdateTo($version, $html_output = true) {
if ($this->getSchemaVersion() == $version - 1) {
function update_to($version, $html_output = true) {
if ($this->get_schema_version() == $version - 1) {
$lines = $this->getSchemaLines($version);
$lines = $this->get_schema_lines($version);
if (is_array($lines)) {
@ -63,7 +63,7 @@ class DbUpdater {
}
}
$db_version = $this->getSchemaVersion();
$db_version = $this->get_schema_version();
if ($db_version == $version) {
$this->pdo->commit();

@ -1,12 +1,6 @@
<?php
class Digest
{
/**
* Send by mail a digest of last articles.
*
* @return boolean Return false if digests are not enabled.
*/
static function send_headlines_digests() {
$user_limit = 15; // amount of users to process (e.g. emails to send out)
@ -14,9 +8,9 @@ class Digest
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "last_digest_sent < NOW() - INTERVAL '1 days'";
} else /* if (DB_TYPE == "mysql") */ {
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "last_digest_sent < DATE_SUB(NOW(), INTERVAL 1 DAY)";
}
@ -54,11 +48,11 @@ class Digest
$mailer = new Mailer();
//$rc = $mail->quickMail($line["email"], $line["login"], DIGEST_SUBJECT, $digest, $digest_text);
//$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text);
$rc = $mailer->mail(["to_name" => $line["login"],
"to_address" => $line["email"],
"subject" => DIGEST_SUBJECT,
"subject" => Config::get(Config::DIGEST_SUBJECT),
"message" => $digest_text,
"message_html" => $digest]);
@ -68,7 +62,7 @@ class Digest
if ($rc && $do_catchup) {
Debug::log("Marking affected articles as read...");
Article::catchupArticlesById($affected_ids, 0, $line["id"]);
Article::_catchup_by_id($affected_ids, 0, $line["id"]);
}
} else {
Debug::log("No headlines");
@ -81,9 +75,7 @@ class Digest
}
}
}
Debug::log("All done.");
}
static function prepare_headlines_digest($user_id, $days = 1, $limit = 1000) {
@ -99,19 +91,19 @@ class Digest
$tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
$tpl->setVariable('CUR_TIME', date('G:i', $local_ts));
$tpl->setVariable('TTRSS_HOST', SELF_URL_PATH);
$tpl->setVariable('TTRSS_HOST', Config::get(Config::get(Config::SELF_URL_PATH)));
$tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
$tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts));
$tpl_t->setVariable('TTRSS_HOST', SELF_URL_PATH);
$tpl_t->setVariable('TTRSS_HOST', Config::get(Config::get(Config::SELF_URL_PATH)));
$affected_ids = array();
$days = (int) $days;
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'";
} else /* if (DB_TYPE == "mysql") */ {
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)";
}
@ -164,7 +156,7 @@ class Digest
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
}
$article_labels = Article::get_article_labels($line["ref_id"], $user_id);
$article_labels = Article::_get_labels($line["ref_id"], $user_id);
$article_labels_formatted = "";
if (is_array($article_labels) && count($article_labels) > 0) {
@ -210,5 +202,4 @@ class Digest
return array($tmp, $headlines_count, $affected_ids, $tmp_t);
}
}

@ -191,23 +191,23 @@ class DiskCache {
];
public function __construct($dir) {
$this->dir = CACHE_DIR . "/" . basename(clean($dir));
$this->dir = Config::get(Config::CACHE_DIR) . "/" . basename(clean($dir));
}
public function getDir() {
public function get_dir() {
return $this->dir;
}
public function makeDir() {
public function make_dir() {
if (!is_dir($this->dir)) {
return mkdir($this->dir);
}
}
public function isWritable($filename = "") {
public function is_writable($filename = "") {
if ($filename) {
if (file_exists($this->getFullPath($filename)))
return is_writable($this->getFullPath($filename));
if (file_exists($this->get_full_path($filename)))
return is_writable($this->get_full_path($filename));
else
return is_writable($this->dir);
} else {
@ -216,44 +216,44 @@ class DiskCache {
}
public function exists($filename) {
return file_exists($this->getFullPath($filename));
return file_exists($this->get_full_path($filename));
}
public function getSize($filename) {
public function get_size($filename) {
if ($this->exists($filename))
return filesize($this->getFullPath($filename));
return filesize($this->get_full_path($filename));
else
return -1;
}
public function getFullPath($filename) {
public function get_full_path($filename) {
return $this->dir . "/" . basename(clean($filename));
}
public function put($filename, $data) {
return file_put_contents($this->getFullPath($filename), $data);
return file_put_contents($this->get_full_path($filename), $data);
}
public function touch($filename) {
return touch($this->getFullPath($filename));
return touch($this->get_full_path($filename));
}
public function get($filename) {
if ($this->exists($filename))
return file_get_contents($this->getFullPath($filename));
return file_get_contents($this->get_full_path($filename));
else
return null;
}
public function getMimeType($filename) {
public function get_mime_type($filename) {
if ($this->exists($filename))
return mime_content_type($this->getFullPath($filename));
return mime_content_type($this->get_full_path($filename));
else
return null;
}
public function getFakeExtension($filename) {
$mimetype = $this->getMimeType($filename);
public function get_fake_extension($filename) {
$mimetype = $this->get_mime_type($filename);
if ($mimetype)
return isset($this->mimeMap[$mimetype]) ? $this->mimeMap[$mimetype] : "";
@ -262,25 +262,25 @@ class DiskCache {
}
public function send($filename) {
$fake_extension = $this->getFakeExtension($filename);
$fake_extension = $this->get_fake_extension($filename);
if ($fake_extension)
$fake_extension = ".$fake_extension";
header("Content-Disposition: inline; filename=\"${filename}${fake_extension}\"");
return $this->send_local_file($this->getFullPath($filename));
return $this->send_local_file($this->get_full_path($filename));
}
public function getUrl($filename) {
return get_self_url_prefix() . "/public.php?op=cached_url&file=" . basename($this->dir) . "/" . basename($filename);
public function get_url($filename) {
return get_self_url_prefix() . "/public.php?op=cached&file=" . basename($this->dir) . "/" . basename($filename);
}
// check for locally cached (media) URLs and rewrite to local versions
// this is called separately after sanitize() and plugin render article hooks to allow
// plugins work on original source URLs used before caching
// NOTE: URLs should be already absolutized because this is called after sanitize()
static public function rewriteUrls($str)
static public function rewrite_urls($str)
{
$res = trim($str);
if (!$res) return '';
@ -301,7 +301,7 @@ class DiskCache {
$cached_filename = sha1($url);
if ($cache->exists($cached_filename)) {
$url = $cache->getUrl($cached_filename);
$url = $cache->get_url($cached_filename);
$entry->setAttribute($attr, $url);
$entry->removeAttribute("srcset");
@ -318,7 +318,7 @@ class DiskCache {
$cached_filename = sha1($matches[$i]["url"]);
if ($cache->exists($cached_filename)) {
$matches[$i]["url"] = $cache->getUrl($cached_filename);
$matches[$i]["url"] = $cache->get_url($cached_filename);
$need_saving = true;
}
@ -339,7 +339,7 @@ class DiskCache {
}
static function expire() {
$dirs = array_filter(glob(CACHE_DIR . "/*"), "is_dir");
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
foreach ($dirs as $cache_dir) {
$num_deleted = 0;
@ -349,7 +349,7 @@ class DiskCache {
if ($files) {
foreach ($files as $file) {
if (time() - filemtime($file) > 86400*CACHE_MAX_DAYS) {
if (time() - filemtime($file) > 86400*Config::get(Config::CACHE_MAX_DAYS)) {
unlink($file);
++$num_deleted;
@ -396,7 +396,7 @@ class DiskCache {
$tmppluginhost = new PluginHost();
$tmppluginhost->load(PLUGINS, PluginHost::KIND_SYSTEM);
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
//$tmppluginhost->load_data();
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))

@ -0,0 +1,12 @@
<?php
class Errors {
const E_SUCCESS = "E_SUCCESS";
const E_UNAUTHORIZED = "E_UNAUTHORIZED";
const E_UNKNOWN_METHOD = "E_UNKNOWN_METHOD";
const E_UNKNOWN_PLUGIN = "E_UNKNOWN_PLUGIN";
const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH";
static function to_json(string $code) {
return json_encode(["error" => ["code" => $code]]);
}
}

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

@ -119,7 +119,7 @@ class FeedItem_Atom extends FeedItem_Common {
return $this->normalize_categories($cats);
}
function get_enclosures() {
function _get_enclosures() {
$links = $this->elem->getElementsByTagName("link");
$encs = array();
@ -138,7 +138,7 @@ class FeedItem_Atom extends FeedItem_Common {
}
}
$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);
@ -179,7 +179,7 @@ abstract class FeedItem_Common extends FeedItem {
$cat = preg_replace('/[,\'\"]/', "", $cat);
if (DB_TYPE == "mysql") {
if (Config::get(Config::DB_TYPE) == "mysql") {
$cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat);
}

@ -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;
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,11 @@
<?php
class Handler_Administrative extends Handler_Protected {
function before($method) {
if (parent::before($method)) {
if (($_SESSION["access_level"] ?? 0) >= 10) {
return true;
}
}
return false;
}
}

@ -2,6 +2,6 @@
class Handler_Protected extends Handler {
function before($method) {
return parent::before($method) && $_SESSION['uid'];
return parent::before($method) && !empty($_SESSION['uid']);
}
}

@ -12,7 +12,7 @@ class Handler_Public extends Handler {
if (!$limit) $limit = 60;
list($override_order, $skip_first_id_check) = Feeds::order_to_override_query($order);
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query($order);
if (!$override_order) {
$override_order = "date_entered DESC, updated DESC";
@ -43,7 +43,7 @@ class Handler_Public extends Handler {
$user_plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
$tmppluginhost = new PluginHost();
$tmppluginhost->load(PLUGINS, PluginHost::KIND_ALL);
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
$tmppluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid);
//$tmppluginhost->load_data();
@ -55,7 +55,7 @@ class Handler_Public extends Handler {
}
} else {
$qfh_ret = Feeds::queryFeedHeadlines($params);
$qfh_ret = Feeds::_get_headlines($params);
}
$result = $qfh_ret[0];
@ -65,7 +65,7 @@ class Handler_Public extends Handler {
$feed_self_url = get_self_url_prefix() .
"/public.php?op=rss&id=$feed&key=" .
Feeds::get_feed_access_key($feed, false, $owner_uid);
Feeds::_get_access_key($feed, false, $owner_uid);
if (!$feed_site_url) $feed_site_url = get_self_url_prefix();
@ -82,7 +82,7 @@ class Handler_Public extends Handler {
while ($line = $result->fetch()) {
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
$line["tags"] = Article::get_article_tags($line["id"], $owner_uid);
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
@ -98,7 +98,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('ARTICLE_ID',
htmlspecialchars($orig_guid ? $line['link'] :
$this->make_article_tag_uri($line['id'], $line['date_entered'])), true);
$this->_make_article_tag_uri($line['id'], $line['date_entered'])), true);
$tpl->setVariable('ARTICLE_LINK', htmlspecialchars($line['link']), true);
$tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true);
$tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true);
@ -106,7 +106,7 @@ class Handler_Public extends Handler {
$content = Sanitizer::sanitize($line["content"], false, $owner_uid,
$feed_site_url, false, $line["id"]);
$content = DiskCache::rewriteUrls($content);
$content = DiskCache::rewrite_urls($content);
if ($line['note']) {
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" .
@ -131,7 +131,7 @@ class Handler_Public extends Handler {
$tpl->addBlock('category');
}
$enclosures = Article::get_article_enclosures($line["id"]);
$enclosures = Article::_get_enclosures($line["id"]);
if (count($enclosures) > 0) {
foreach ($enclosures as $e) {
@ -146,12 +146,12 @@ class Handler_Public extends Handler {
$tpl->addBlock('enclosure');
}
} else {
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', null, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', null, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', null, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', "", true);
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', "", true);
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true);
}
list ($og_image, $og_stream) = Article::get_article_image($enclosures, $line['content'], $feed_site_url);
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url);
$tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true);
@ -163,7 +163,7 @@ class Handler_Public extends Handler {
$tpl->addBlock('feed');
$tpl->generateOutputToString($tmp);
if (@!clean($_REQUEST["noxml"])) {
if (empty($_REQUEST["noxml"])) {
header("Content-Type: text/xml; charset=utf-8");
} else {
header("Content-Type: text/plain; charset=utf-8");
@ -184,7 +184,7 @@ class Handler_Public extends Handler {
while ($line = $result->fetch()) {
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content_preview"]), 100, '...'));
$line["tags"] = Article::get_article_tags($line["id"], $owner_uid);
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
@ -207,8 +207,8 @@ class Handler_Public extends Handler {
$article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, false, $line["id"]);
$article['updated'] = date('c', strtotime($line["updated"]));
if ($line['note']) $article['note'] = $line['note'];
if ($article['author']) $article['author'] = $line['author'];
if (!empty($line['note'])) $article['note'] = $line['note'];
if (!empty($line['author'])) $article['author'] = $line['author'];
if (count($line["tags"]) > 0) {
$article['tags'] = array();
@ -218,7 +218,7 @@ class Handler_Public extends Handler {
}
}
$enclosures = Article::get_article_enclosures($line["id"]);
$enclosures = Article::_get_enclosures($line["id"]);
if (count($enclosures) > 0) {
$article['enclosures'] = array();
@ -240,7 +240,7 @@ class Handler_Public extends Handler {
} else {
header("Content-Type: text/plain; charset=utf-8");
print json_encode(array("error" => array("message" => "Unknown format")));
print "Unknown format: $format.";
}
}
@ -251,11 +251,11 @@ class Handler_Public extends Handler {
$uid = UserHelper::find_user_by_login($login);
if ($uid) {
print Feeds::getGlobalUnread($uid);
print Feeds::_get_global_unread($uid);
if ($fresh) {
print ";";
print Feeds::getFeedArticles(-3, false, true, $uid);
print Feeds::_get_counters(-3, false, true, $uid);
}
} else {
print "-1;User not found";
@ -286,195 +286,30 @@ class Handler_Public extends Handler {
function logout() {
if (validate_csrf($_POST["csrf_token"])) {
Pref_Users::logout_user();
UserHelper::logout();
header("Location: index.php");
} else {
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
}
function share() {
$uuid = clean($_REQUEST["key"]);
if ($uuid) {
$sth = $this->pdo->prepare("SELECT ref_id, owner_uid
FROM ttrss_user_entries WHERE uuid = ?");
$sth->execute([$uuid]);
if ($row = $sth->fetch()) {
header("Content-Type: text/html");
$id = $row["ref_id"];
$owner_uid = $row["owner_uid"];
print $this->format_article($id, $owner_uid);
return;
}
}
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "Article not found.";
}
private function format_article($id, $owner_uid) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id,title,link,content,feed_id,comments,int_id,lang,
".SUBSTRING_FOR_DATE."(updated,1,16) as updated,
(SELECT site_url FROM ttrss_feeds WHERE id = feed_id) as site_url,
(SELECT title FROM ttrss_feeds WHERE id = feed_id) as feed_title,
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) as hide_images,
(SELECT always_display_enclosures FROM ttrss_feeds WHERE id = feed_id) as always_display_enclosures,
num_comments,
tag_cache,
author,
guid,
note
FROM ttrss_entries,ttrss_user_entries
WHERE id = ? AND ref_id = id AND owner_uid = ?");
$sth->execute([$id, $owner_uid]);
$rv = '';
if ($line = $sth->fetch()) {
$line["tags"] = Article::get_article_tags($id, $owner_uid, $line["tag_cache"]);
unset($line["tag_cache"]);
$line["content"] = Sanitizer::sanitize($line["content"],
$line['hide_images'],
$owner_uid, $line["site_url"], false, $line["id"]);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE,
function ($result) use (&$line) {
$line = $result;
},
$line);
$line['content'] = DiskCache::rewriteUrls($line['content']);
$enclosures = Article::get_article_enclosures($line["id"]);
header("Content-Type: text/html");
$rv .= "<!DOCTYPE html>
<html><head>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'/>
<title>".$line["title"]."</title>".
javascript_tag("lib/prototype.js").
javascript_tag("js/utility.js")."
<style type='text/css'>
@media (prefers-color-scheme: dark) {
body {
background : #222;
}
}
body.css_loading * {
display : none;
}
</style>
<link rel='shortcut icon' type='image/png' href='images/favicon.png'>
<link rel='icon' type='image/png' sizes='72x72' href='images/favicon-72px.png'>";
$rv .= "<meta property='og:title' content=\"".htmlspecialchars(html_entity_decode($line["title"], ENT_NOQUOTES | ENT_HTML401))."\"/>\n";
$rv .= "<meta property='og:description' content=\"".
htmlspecialchars(
truncate_string(
preg_replace("/[\r\n\t]/", "",
preg_replace("/ {1,}/", " ",
strip_tags(html_entity_decode($line["content"], ENT_NOQUOTES | ENT_HTML401))
)
), 500, "...")
)."\"/>\n";
$rv .= "</head>";
list ($og_image, $og_stream) = Article::get_article_image($enclosures, $line['content'], $line["site_url"]);
if ($og_image) {
$rv .= "<meta property='og:image' content=\"" . htmlspecialchars($og_image) . "\"/>";
}
$rv .= "<body class='flat ttrss_utility ttrss_zoom css_loading'>";
$rv .= "<div class='container'>";
if ($line["link"]) {
$rv .= "<h1><a target='_blank' rel='noopener noreferrer'
title=\"".htmlspecialchars($line['title'])."\"
href=\"" .htmlspecialchars($line["link"]) . "\">" . $line["title"] . "</a></h1>";
} else {
$rv .= "<h1>" . $line["title"] . "</h1>";
}
$rv .= "<div class='content post'>";
/* header */
$rv .= "<div class='header'>";
$rv .= "<div class='row'>"; # row
//$entry_author = $line["author"] ? " - " . $line["author"] : "";
$parsed_updated = TimeHelper::make_local_datetime($line["updated"], true,
$owner_uid, true);
$rv .= "<div>".$line['author']."</div>";
$rv .= "<div>$parsed_updated</div>";
$rv .= "</div>"; # row
$rv .= "</div>"; # header
/* content */
$lang = $line['lang'] ? $line['lang'] : "en";
$rv .= "<div class='content' lang='$lang'>";
/* content body */
$rv .= $line["content"];
$rv .= Article::format_article_enclosures($id,
$line["always_display_enclosures"],
$line["content"],
$line["hide_images"]);
$rv .= "</div>"; # content
$rv .= "</div>"; # post
}
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ARTICLE,
function ($result) use (&$rv) {
$rv = $result;
},
$rv, $line);
return $rv;
}
function rss() {
$feed = clean($_REQUEST["id"]);
$key = clean($_REQUEST["key"]);
$is_cat = clean($_REQUEST["is_cat"]);
$limit = (int)clean($_REQUEST["limit"]);
$offset = (int)clean($_REQUEST["offset"]);
$search = clean($_REQUEST["q"]);
$view_mode = clean($_REQUEST["view-mode"]);
$order = clean($_REQUEST["order"]);
$start_ts = clean($_REQUEST["ts"]);
$is_cat = clean($_REQUEST["is_cat"] ?? false);
$limit = (int)clean($_REQUEST["limit"] ?? 0);
$offset = (int)clean($_REQUEST["offset"] ?? 0);
$format = clean($_REQUEST['format']);
$orig_guid = clean($_REQUEST["orig_guid"]);
$search = clean($_REQUEST["q"] ?? "");
$view_mode = clean($_REQUEST["view-mode"] ?? "");
$order = clean($_REQUEST["order"] ?? "");
$start_ts = (int)clean($_REQUEST["ts"] ?? 0);
if (!$format) $format = 'atom';
$format = clean($_REQUEST['format'] ?? "atom");
$orig_guid = clean($_REQUEST["orig_guid"] ?? false);
if (SINGLE_USER_MODE) {
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate("admin", null);
}
@ -511,169 +346,8 @@ class Handler_Public extends Handler {
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
}
function sharepopup() {
if (SINGLE_USER_MODE) {
UserHelper::login_sequence();
}
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
<title><?php echo __("Share with Tiny Tiny RSS") ?></title>
<?php
echo javascript_tag("lib/prototype.js");
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("js/utility.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
echo javascript_tag("lib/scriptaculous/scriptaculous.js?load=effects,controls")
?>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<style type="text/css">
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
</head>
<body class='flat ttrss_utility share_popup css_loading'>
<script type="text/javascript">
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();
new Ajax.Autocompleter('labels_value', 'labels_choices',
"backend.php?op=rpc&method=completeLabels",
{ tokens: ',', paramName: "search" });
});
});
}
};
</script>
<div class="content">
<?php
$action = clean($_REQUEST["action"]);
if ($_SESSION["uid"]) {
if ($action == 'share') {
$title = strip_tags(clean($_REQUEST["title"]));
$url = strip_tags(clean($_REQUEST["url"]));
$content = strip_tags(clean($_REQUEST["content"]));
$labels = strip_tags(clean($_REQUEST["labels"]));
Article::create_published_article($title, $url, $content, $labels,
$_SESSION["uid"]);
print "<script type='text/javascript'>";
print "window.close();";
print "</script>";
} else {
$title = htmlspecialchars(clean($_REQUEST["title"]));
$url = htmlspecialchars(clean($_REQUEST["url"]));
?>
<form id='share_form' name='share_form'>
<input type="hidden" name="op" value="sharepopup">
<input type="hidden" name="action" value="share">
<fieldset>
<label><?php echo __("Title:") ?></label>
<input style='width : 270px' dojoType='dijit.form.TextBox' name='title' value="<?php echo $title ?>">
</fieldset>
<fieldset>
<label><?php echo __("URL:") ?></label>
<input style='width : 270px' name='url' dojoType='dijit.form.TextBox' value="<?php echo $url ?>">
</fieldset>
<fieldset>
<label><?php echo __("Content:") ?></label>
<input style='width : 270px' name='content' dojoType='dijit.form.TextBox' value="">
</fieldset>
<fieldset>
<label><?php echo __("Labels:") ?></label>
<input style='width : 270px' name='labels' dojoType='dijit.form.TextBox' id="labels_value"
placeholder='Alpha, Beta, Gamma' value="">
<div class="autocomplete" id="labels_choices"
style="display : block"></div>
</fieldset>
<hr/>
<fieldset>
<button dojoType='dijit.form.Button' class="alt-primary" type="submit"><?php echo __('Share') ?></button>
<button dojoType='dijit.form.Button' onclick="return window.close()"><?php echo __('Cancel') ?></button>
<span class="text-muted small"><?php echo __("Shared article will appear in the Published feed.") ?></span>
</fieldset>
</form>
<?php
}
} else {
$return = urlencode(make_self_url());
?>
<?php print_error("Not logged in"); ?>
<form action="public.php?return=<?php echo $return ?>" method="post">
<input type="hidden" name="op" value="login">
<fieldset>
<label><?php echo __("Login:") ?></label>
<input name="login" id="login" dojoType="dijit.form.TextBox" type="text"
onchange="fetchProfiles()" onfocus="fetchProfiles()" onblur="fetchProfiles()"
required="1" value="<?php echo $_SESSION["fake_login"] ?>" />
</fieldset>
<fieldset>
<label><?php echo __("Password:") ?></label>
<input type="password" name="password" required="1"
dojoType="dijit.form.TextBox"
class="input input-text"
value="<?php echo $_SESSION["fake_password"] ?>"/>
</fieldset>
<hr/>
<fieldset>
<label> </label>
<button dojoType="dijit.form.Button" type="submit" class="alt-primary"><?php echo __('Log in') ?></button>
</fieldset>
</form>
<?php
}
print "</div></body></html>";
}
function login() {
if (!SINGLE_USER_MODE) {
if (!Config::get(Config::SINGLE_USER_MODE)) {
$login = clean($_POST["login"]);
$password = clean($_POST["password"]);
@ -681,7 +355,7 @@ class Handler_Public extends Handler {
$safe_mode = checkbox_to_sql_bool(clean($_POST["safe_mode"] ?? false));
if ($remember_me) {
@session_set_cookie_params(SESSION_COOKIE_LIFETIME);
@session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME));
} else {
@session_set_cookie_params(0);
}
@ -724,7 +398,7 @@ class Handler_Public extends Handler {
$return = clean($_REQUEST['return']);
if ($_REQUEST['return'] && mb_strpos($return, SELF_URL_PATH) === 0) {
if ($_REQUEST['return'] && mb_strpos($return, Config::get(Config::SELF_URL_PATH)) === 0) {
header("Location: " . clean($_REQUEST['return']));
} else {
header("Location: " . get_self_url_prefix());
@ -732,164 +406,9 @@ class Handler_Public extends Handler {
}
}
function subscribe() {
if (SINGLE_USER_MODE) {
UserHelper::login_sequence();
}
if (!empty($_SESSION["uid"])) {
$feed_url = clean($_REQUEST["feed_url"] ?? "");
$csrf_token = clean($_POST["csrf_token"] ?? "");
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
<title>Tiny Tiny RSS</title>
<?php
echo javascript_tag("lib/prototype.js");
echo javascript_tag("js/utility.js");
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
?>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<style type="text/css">
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
</head>
<body class='flat ttrss_utility css_loading'>
<script type="text/javascript">
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();
});
});
}
};
</script>
<div class="container">
<h1><?php echo __("Subscribe to feed...") ?></h1>
<div class='content'>
<?php
if (!$feed_url || !validate_csrf($csrf_token)) {
?>
<form method="post">
<input type="hidden" name="op" value="subscribe">
<?php print_hidden("csrf_token", $_SESSION["csrf_token"]) ?>
<fieldset>
<label>Feed or site URL:</label>
<input style="width: 300px" dojoType="dijit.form.ValidationTextBox" required="1" name="feed_url" value="<?php echo htmlspecialchars($feed_url) ?>">
</fieldset>
<button class="alt-primary" dojoType="dijit.form.Button" type="submit">
<?php echo __("Subscribe") ?>
</button>
<a href="index.php"><?php echo __("Return to Tiny Tiny RSS") ?></a>
</form>
<?php
} else {
$rc = Feeds::subscribe_to_feed($feed_url);
$feed_urls = false;
switch ($rc['code']) {
case 0:
print_warning(T_sprintf("Already subscribed to <b>%s</b>.", $feed_url));
break;
case 1:
print_notice(T_sprintf("Subscribed to <b>%s</b>.", $feed_url));
break;
case 2:
print_error(T_sprintf("Could not subscribe to <b>%s</b>.", $feed_url));
break;
case 3:
print_error(T_sprintf("No feeds found in <b>%s</b>.", $feed_url));
break;
case 4:
$feed_urls = $rc["feeds"];
break;
case 5:
print_error(T_sprintf("Could not subscribe to <b>%s</b>.<br>Can't download the Feed URL.", $feed_url));
break;
}
if ($feed_urls) {
print "<form action='public.php'>";
print "<input type='hidden' name='op' value='subscribe'>";
print_hidden("csrf_token", $_SESSION["csrf_token"]);
print "<fieldset>";
print "<label style='display : inline'>" . __("Multiple feed URLs found:") . "</label>";
print "<select name='feed_url' dojoType='dijit.form.Select'>";
foreach ($feed_urls as $url => $name) {
$url = htmlspecialchars($url);
$name = htmlspecialchars($name);
print "<option value=\"$url\">$name</option>";
}
print "</select>";
print "</fieldset>";
print "<button class='alt-primary' dojoType='dijit.form.Button' type='submit'>".__("Subscribe to selected feed")."</button>";
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
print "</form>";
}
$tp_uri = get_self_url_prefix() . "/prefs.php";
if ($rc['code'] <= 2){
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
feed_url = ? AND owner_uid = ?");
$sth->execute([$feed_url, $_SESSION['uid']]);
$row = $sth->fetch();
$feed_id = $row["id"];
} else {
$feed_id = 0;
}
if ($feed_id) {
print "<form method='GET' action=\"$tp_uri\">
<input type='hidden' name='tab' value='feeds'>
<input type='hidden' name='method' value='editfeed'>
<input type='hidden' name='methodparam' value='$feed_id'>
<button dojoType='dijit.form.Button' class='alt-info' type='submit'>".__("Edit subscription options")."</button>
<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>
</form>";
}
}
print "</div></div></body></html>";
} else {
$this->render_login_form();
}
}
function index() {
header("Content-Type: text/plain");
print error_json(13);
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
function forgotpass() {
@ -909,7 +428,6 @@ class Handler_Public extends Handler {
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<?php
echo stylesheet_tag("themes/light.css");
echo javascript_tag("lib/prototype.js");
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
?>
@ -953,7 +471,7 @@ class Handler_Public extends Handler {
WHERE id = ?");
$sth->execute([$id]);
Pref_Users::resetUserPassword($id, true);
UserHelper::reset_password($id, true);
print "<p>"."Completed."."</p>";
@ -1041,7 +559,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('LOGIN', $login);
$tpl->setVariable('RESETPASS_LINK', $resetpass_link);
$tpl->setVariable('TTRSS_HOST', SELF_URL_PATH);
$tpl->setVariable('TTRSS_HOST', Config::get(Config::SELF_URL_PATH));
$tpl->addBlock('message');
@ -1095,9 +613,9 @@ class Handler_Public extends Handler {
function dbupdate() {
startup_gettext();
if (!SINGLE_USER_MODE && $_SESSION["access_level"] < 10) {
if (!Config::get(Config::SINGLE_USER_MODE) && $_SESSION["access_level"] < 10) {
$_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script.");
$this->render_login_form();
$this->_render_login_form();
exit;
}
@ -1107,12 +625,11 @@ class Handler_Public extends Handler {
<head>
<title>Database Updater</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<?php echo stylesheet_tag("themes/light.css") ?>
<?= 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">
<?php
echo stylesheet_tag("themes/light.css");
echo javascript_tag("lib/prototype.js");
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
?>
@ -1137,26 +654,26 @@ class Handler_Public extends Handler {
</script>
<div class="container">
<h1><?php echo __("Database Updater") ?></h1>
<h1><?= __("Database Updater") ?></h1>
<div class="content">
<?php
@$op = clean($_REQUEST["subop"]);
$updater = new DbUpdater(Db::pdo(), DB_TYPE, SCHEMA_VERSION);
@$op = clean($_REQUEST["subop"] ?? "");
$updater = new DbUpdater(Db::pdo(), Config::get(Config::DB_TYPE), SCHEMA_VERSION);
if ($op == "performupdate") {
if ($updater->isUpdateRequired()) {
if ($updater->is_update_required()) {
print "<h2>" . T_sprintf("Performing updates to version %d", SCHEMA_VERSION) . "</h2>";
for ($i = $updater->getSchemaVersion() + 1; $i <= SCHEMA_VERSION; $i++) {
for ($i = $updater->get_schema_version() + 1; $i <= SCHEMA_VERSION; $i++) {
print "<ul>";
print "<li class='text-info'>" . T_sprintf("Updating to version %d", $i) . "</li>";
print "<li>";
$result = $updater->performUpdateTo($i, true);
$result = $updater->update_to($i, true);
print "</li>";
if (!$result) {
@ -1187,12 +704,12 @@ class Handler_Public extends Handler {
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
}
} else {
if ($updater->isUpdateRequired()) {
if ($updater->is_update_required()) {
print "<h2>".T_sprintf("Tiny Tiny RSS database needs update to the latest version (%d to %d).",
$updater->getSchemaVersion(), SCHEMA_VERSION)."</h2>";
$updater->get_schema_version(), SCHEMA_VERSION)."</h2>";
if (DB_TYPE == "mysql") {
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 {
@ -1220,7 +737,28 @@ class Handler_Public extends Handler {
<?php
}
function cached_url() {
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);
// we do not allow files with extensions at the moment
@ -1236,7 +774,7 @@ class Handler_Public extends Handler {
}
}
private function make_article_tag_uri($id, $timestamp) {
private function _make_article_tag_uri($id, $timestamp) {
$timestamp = date("Y-m-d", strtotime($timestamp));
@ -1264,21 +802,21 @@ class Handler_Public extends Handler {
} else {
user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(6);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(13);
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
} else {
user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: text/json");
print error_json(14);
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
}
}
static function render_login_form() {
static function _render_login_form() {
header('Cache-Control: public');
require_once "login_form.php";

@ -1,13 +0,0 @@
<?php
interface IDb {
function connect($host, $user, $pass, $db, $port);
function escape_string($s, $strip_tags = true);
function query($query, $die_on_error = true);
function fetch_assoc($result);
function num_rows($result);
function fetch_result($result, $row, $param);
function close();
function affected_rows($result);
function last_error();
function last_query_error();
}

@ -37,7 +37,18 @@ class Labels
}
}
static function get_all_labels($owner_uid) {
static function get_as_hash($owner_uid) {
$rv = [];
$labels = Labels::get_all($owner_uid);
foreach ($labels as $i => $label) {
$rv[$label["id"]] = $labels[$i];
}
return $rv;
}
static function get_all($owner_uid) {
$rv = array();
$pdo = Db::pdo();
@ -46,7 +57,7 @@ class Labels
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
array_push($rv, $line);
}
@ -60,7 +71,7 @@ class Labels
self::clear_cache($id);
if (!$labels)
$labels = Article::get_article_labels($id);
$labels = Article::_get_labels($id);
$labels = json_encode($labels);

@ -42,7 +42,7 @@ class Logger {
}
function __construct() {
switch (LOG_DESTINATION) {
switch (Config::get(Config::LOG_DESTINATION)) {
case "sql":
$this->adapter = new Logger_SQL();
break;

@ -11,15 +11,15 @@ class Mailer {
$subject = $params["subject"];
$message = $params["message"];
$message_html = $params["message_html"];
$from_name = $params["from_name"] ? $params["from_name"] : SMTP_FROM_NAME;
$from_address = $params["from_address"] ? $params["from_address"] : SMTP_FROM_ADDRESS;
$from_name = $params["from_name"] ? $params["from_name"] : Config::get(Config::SMTP_FROM_NAME);
$from_address = $params["from_address"] ? $params["from_address"] : Config::get(Config::SMTP_FROM_ADDRESS);
$additional_headers = $params["headers"] ? $params["headers"] : [];
$from_combined = $from_name ? "$from_name <$from_address>" : $from_address;
$to_combined = $to_name ? "$to_name <$to_address>" : $to_address;
if (defined('_LOG_SENT_MAIL') && _LOG_SENT_MAIL)
if (Config::get(Config::LOG_SENT_MAIL))
Logger::get()->log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
// HOOK_SEND_MAIL plugin instructions:

@ -31,7 +31,7 @@ class OPML extends Handler_Protected {
<body class='claro ttrss_utility'>
<h1>".__('OPML Utility')."</h1><div class='content'>";
Feeds::add_feed_category("Imported feeds");
Feeds::_add_cat("Imported feeds");
$this->opml_notice(__("Importing OPML..."));
@ -205,7 +205,7 @@ class OPML extends Handler_Protected {
if (!$tmp_line["match_on"]) {
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::getFeedTitle(
$tmp_line["feed"] = Feeds::_get_title(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$cat_filter);
} else {
@ -218,13 +218,13 @@ class OPML extends Handler_Protected {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::getCategoryTitle($feed_id), true, false]);
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::getFeedTitle((int)$feed_id), false, false]);
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
} else {
array_push($match, [0, false, true]);
}
@ -523,7 +523,7 @@ class OPML extends Handler_Protected {
$order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue;
if (!$order_id) $order_id = 0;
Feeds::add_feed_category($cat_title, $parent_id, $order_id);
Feeds::_add_cat($cat_title, $parent_id, $order_id);
$cat_id = $this->get_feed_category($cat_title, $parent_id);
}
@ -594,7 +594,7 @@ class OPML extends Handler_Protected {
}
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
$tmp_file = (string)tempnam(CACHE_DIR . '/upload', 'opml');
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
$tmp_file);
@ -634,13 +634,10 @@ class OPML extends Handler_Protected {
print "$msg<br/>";
}
static function opml_publish_url(){
$url_path = get_self_url_prefix();
$url_path .= "/opml.php?op=publish&key=" .
Feeds::get_feed_access_key('OPML:Publish', false, $_SESSION["uid"]);
return $url_path;
static function get_publish_url(){
return get_self_url_prefix() .
"/public.php?op=publishOpml&key=" .
Feeds::_get_access_key('OPML:Publish', false, $_SESSION["uid"]);
}
function get_feed_category($feed_cat, $parent_cat_id = false) {

@ -54,4 +54,8 @@ abstract class Plugin {
return vsprintf($this->__($msgid), $args);
}
function csrf_ignore($method) {
return false;
}
}

@ -7,17 +7,23 @@ class PluginHandler extends Handler_Protected {
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)) {
$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("PluginHandler: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
print error_json(13);
user_error("Rejected ${plugin_name}->${method}(): unknown method.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
} else {
user_error("PluginHandler: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
print error_json(14);
user_error("Rejected ${plugin_name}->${method}(): unknown plugin.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
}
}
}

@ -18,6 +18,7 @@ class PluginHost {
private static $instance;
const API_VERSION = 2;
const PUBLIC_METHOD_DELIMITER = "--";
// Hooks marked with *1 are run in global context and available
// to plugins loaded in config.php only
@ -47,14 +48,14 @@ class PluginHost {
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_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, $hide_images)
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($row, $id) (byref)
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)
@ -107,8 +108,9 @@ class PluginHost {
return false;
}
// needed for compatibility with API 2 (?)
function get_dbh() {
return Db::get();
return false;
}
function get_pdo(): PDO {
@ -272,8 +274,8 @@ class PluginHost {
$class = trim($class);
$class_file = strtolower(basename(clean($class)));
if (!is_dir(__DIR__."/../plugins/$class_file") &&
!is_dir(__DIR__."/../plugins.local/$class_file")) continue;
if (!is_dir(__DIR__ . "/../plugins/$class_file") &&
!is_dir(__DIR__ . "/../plugins.local/$class_file")) continue;
// try system plugin directory first
$file = __DIR__ . "/../plugins/$class_file/init.php";
@ -598,7 +600,7 @@ class PluginHost {
}
// handled by classes/pluginhandler.php, requires valid session
function get_method_url(Plugin $sender, string $method, $params) {
function get_method_url(Plugin $sender, string $method, $params = []) {
return get_self_url_prefix() . "/backend.php?" .
http_build_query(
array_merge(
@ -610,16 +612,25 @@ class PluginHost {
$params));
}
// shortcut syntax (disabled for now)
/* function get_method_url(Plugin $sender, string $method, $params) {
return get_self_url_prefix() . "/backend.php?" .
http_build_query(
array_merge(
[
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
],
$params));
} */
// WARNING: endpoint in public.php, exposed to unauthenticated users
function get_public_method_url(Plugin $sender, string $method, $params) {
function get_public_method_url(Plugin $sender, string $method, $params = []) {
if ($sender->is_public_method($method)) {
return get_self_url_prefix() . "/public.php?" .
http_build_query(
array_merge(
[
"op" => "pluginhandler",
"plugin" => strtolower(get_class($sender)),
"pmethod" => $method
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
],
$params));
} else {

File diff suppressed because it is too large Load Diff

@ -162,7 +162,7 @@ class Pref_Filters extends Handler_Protected {
print json_encode($rv);
}
private function getfilterrules_list($filter_id) {
private function _get_rules_list($filter_id) {
$sth = $this->pdo->prepare("SELECT reg_exp,
inverse,
match_on,
@ -189,10 +189,10 @@ class Pref_Filters extends Handler_Protected {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::getCategoryTitle($feed_id));
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::getFeedTitle((int)$feed_id));
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
else
array_push($feeds_fmt, __("All feeds"));
}
@ -203,9 +203,9 @@ class Pref_Filters extends Handler_Protected {
} else {
$where = $line["cat_filter"] ?
Feeds::getCategoryTitle($line["cat_id"]) :
Feeds::_get_cat_title($line["cat_id"]) :
($line["feed_id"] ?
Feeds::getFeedTitle($line["feed_id"]) : __("All feeds"));
Feeds::_get_title($line["feed_id"]) : __("All feeds"));
}
# $where = $line["cat_id"] . "/" . $line["feed_id"];
@ -250,7 +250,7 @@ class Pref_Filters extends Handler_Protected {
while ($line = $sth->fetch()) {
$name = $this->getFilterName($line["id"]);
$name = $this->_get_name($line["id"]);
$match_ok = false;
if ($filter_search) {
@ -292,7 +292,7 @@ class Pref_Filters extends Handler_Protected {
$filter['checkbox'] = false;
$filter['last_triggered'] = $line["last_triggered"] ? TimeHelper::make_local_datetime($line["last_triggered"], false) : null;
$filter['enabled'] = sql_bool_to_bool($line["enabled"]);
$filter['rules'] = $this->getfilterrules_list($line['id']);
$filter['rules'] = $this->_get_rules_list($line['id']);
if (!$filter_search || $match_ok) {
array_push($folder['items'], $filter);
@ -319,170 +319,94 @@ class Pref_Filters extends Handler_Protected {
$sth->execute([$filter_id, $_SESSION['uid']]);
if (empty($filter_id) || $row = $sth->fetch()) {
$rv = [
"id" => $filter_id,
"enabled" => $row["enabled"] ?? true,
"match_any_rule" => $row["match_any_rule"] ?? false,
"inverse" => $row["inverse"] ?? false,
"title" => $row["title"] ?? "",
"rules" => [],
"actions" => [],
"filter_types" => [],
"action_types" => [],
"plugin_actions" => [],
"labels" => Labels::get_all($_SESSION["uid"])
];
$res = $this->pdo->query("SELECT id,description
FROM ttrss_filter_types WHERE id != 5 ORDER BY description");
while ($line = $res->fetch()) {
$rv["filter_types"][$line["id"]] = __($line["description"]);
}
$enabled = $row["enabled"] ?? true;
$match_any_rule = $row["match_any_rule"] ?? false;
$inverse = $row["inverse"] ?? false;
$title = htmlspecialchars($row["title"] ?? "");
print "<form onsubmit='return false'>";
print_hidden("op", "pref-filters");
$res = $this->pdo->query("SELECT id,description FROM ttrss_filter_actions
ORDER BY name");
if ($filter_id) {
print_hidden("id", "$filter_id");
print_hidden("method", "editSave");
} else {
print_hidden("method", "add");
while ($line = $res->fetch()) {
$rv["action_types"][$line["id"]] = __($line["description"]);
}
print_hidden("csrf_token", $_SESSION['csrf_token']);
print "<header>".__("Caption")."</header>
<section>
<input required='true' dojoType='dijit.form.ValidationTextBox' style='width : 20em;' name=\"title\" value=\"$title\">
</section>
<header class='horizontal'>".__("Match")."</header>
<section>
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span>" . __('Select')."</span>
<div dojoType='dijit.Menu' style='display: none;'>
<!-- can't use App.dialogOf() here because DropDownButton is not a child of the Dialog -->
<div onclick='dijit.byId(\"filterEditDlg\").selectRules(true)'
dojoType='dijit.MenuItem'>".__('All')."</div>
<div onclick='dijit.byId(\"filterEditDlg\").selectRules(false)'
dojoType='dijit.MenuItem'>".__('None')."</div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).addRule()'>".
__('Add')."</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).deleteRule()'>".
__('Delete')."</button>
</div>";
$filter_actions = PluginHost::getInstance()->get_filter_actions();
print "<ul id='filterDlg_Matches'>";
foreach ($filter_actions as $fclass => $factions) {
foreach ($factions as $faction) {
$rv["plugin_actions"][$fclass . ":" . $faction["action"]] =
$fclass . ": " . $faction["description"];
}
}
if ($filter_id) {
$rules_sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
WHERE filter_id = ? ORDER BY reg_exp, id");
$rules_sth->execute([$filter_id]);
$rules_sth->execute([$filter_id]);
while ($line = $rules_sth->fetch()) {
if ($line["match_on"]) {
$line["feed_id"] = json_decode($line["match_on"], true);
while ($rrow = $rules_sth->fetch(PDO::FETCH_ASSOC)) {
if ($rrow["match_on"]) {
$rrow["feed_id"] = json_decode($rrow["match_on"], true);
} else {
if ($line["cat_filter"]) {
$feed_id = "CAT:" . (int)$line["cat_id"];
if ($rrow["cat_filter"]) {
$feed_id = "CAT:" . (int)$rrow["cat_id"];
} else {
$feed_id = (int)$line["feed_id"];
$feed_id = (int)$rrow["feed_id"];
}
$line["feed_id"] = ["" . $feed_id]; // set item type to string for in_array()
$rrow["feed_id"] = ["" . $feed_id]; // set item type to string for in_array()
}
unset($line["cat_filter"]);
unset($line["cat_id"]);
unset($line["filter_id"]);
unset($line["id"]);
if (!$line["inverse"]) unset($line["inverse"]);
unset($line["match_on"]);
unset($rrow["cat_filter"]);
unset($rrow["cat_id"]);
unset($rrow["filter_id"]);
unset($rrow["id"]);
if (!$rrow["inverse"]) unset($rrow["inverse"]);
unset($rrow["match_on"]);
$data = htmlspecialchars((string)json_encode($line));
$rrow["name"] = $this->_get_rule_name($rrow);
print "<li><input dojoType='dijit.form.CheckBox' type='checkbox' onclick='Lists.onRowChecked(this)'>
<span onclick='App.dialogOf(this).editRule(this)'>".$this->getRuleName($line)."</span>".
format_hidden("rule[]", $data)."</li>";
array_push($rv["rules"], $rrow);
}
}
print "</ul>
</section>";
print "<header class='horizontal'>".__("Apply actions")."</header>
<section>
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span>".__('Select')."</span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick='dijit.byId(\"filterEditDlg\").selectActions(true)'
dojoType='dijit.MenuItem'>".__('All')."</div>
<div onclick='dijit.byId(\"filterEditDlg\").selectActions(false)'
dojoType='dijit.MenuItem'>".__('None')."</div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).addAction()'>".
__('Add')."</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).deleteAction()'>".
__('Delete')."</button>
</div>";
print "<ul id='filterDlg_Actions'>";
if ($filter_id) {
$actions_sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
WHERE filter_id = ? ORDER BY id");
$actions_sth->execute([$filter_id]);
while ($line = $actions_sth->fetch()) {
$line["action_param_label"] = $line["action_param"];
while ($arow = $actions_sth->fetch(PDO::FETCH_ASSOC)) {
$arow["action_param_label"] = $arow["action_param"];
unset($line["filter_id"]);
unset($line["id"]);
unset($arow["filter_id"]);
unset($arow["id"]);
$data = htmlspecialchars((string)json_encode($line));
$arow["name"] = $this->_get_action_name($arow);
print "<li><input dojoType='dijit.form.CheckBox' type='checkbox' onclick='Lists.onRowChecked(this)'>
<span onclick='App.dialogOf(this).editAction(this)'>".$this->getActionName($line)."</span>".
format_hidden("action[]", $data)."</li>";
array_push($rv["actions"], $arow);
}
}
print "</ul>";
print "</section>";
print "<header>".__("Options")."</header>
<section>";
print "<fieldset class='narrow'>
<label class='checkbox'>".format_checkbox('enabled', $enabled)." ".__('Enabled')."</label></fieldset>";
print "<fieldset class='narrow'>
<label class='checkbox'>".format_checkbox('match_any_rule', $match_any_rule)." ".__('Match any rule')."</label>
</fieldset>";
print "<fieldset class='narrow'><label class='checkbox'>".format_checkbox('inverse', $inverse)." ".__('Inverse matching')."</label>
</fieldset>";
print "</section>
<footer>";
if ($filter_id) {
print "<div style='float : left'>
<button dojoType='dijit.form.Button' class='alt-danger' onclick='App.dialogOf(this).removeFilter()'>".
__('Remove')."</button>
</div>
<button dojoType='dijit.form.Button' class='alt-info' onclick='App.dialogOf(this).test()'>".
__('Test')."</button>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick='App.dialogOf(this).execute()'>".
__('Save')."</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>".
__('Cancel')."</button>";
} else {
print "<button dojoType='dijit.form.Button' class='alt-info' onclick='App.dialogOf(this).test()'>".
__('Test')."</button>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick='App.dialogOf(this).execute()'>".
__('Create')."</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>".
__('Cancel')."</button>";
}
print "</footer></form>";
print json_encode($rv);
}
}
private function getRuleName($rule) {
private function _get_rule_name($rule) {
if (!$rule) $rule = json_decode(clean($_REQUEST["rule"]), true);
$feeds = $rule["feed_id"];
@ -494,10 +418,10 @@ class Pref_Filters extends Handler_Protected {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::getCategoryTitle($feed_id));
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::getFeedTitle((int)$feed_id));
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
else
array_push($feeds_fmt, __("All feeds"));
}
@ -523,10 +447,10 @@ class Pref_Filters extends Handler_Protected {
}
function printRuleName() {
print $this->getRuleName(json_decode(clean($_REQUEST["rule"]), true));
print $this->_get_rule_name(json_decode(clean($_REQUEST["rule"]), true));
}
private function getActionName($action) {
private function _get_action_name($action) {
$sth = $this->pdo->prepare("SELECT description FROM
ttrss_filter_actions WHERE id = ?");
$sth->execute([(int)$action["action_id"]]);
@ -561,13 +485,13 @@ class Pref_Filters extends Handler_Protected {
}
function printActionName() {
print $this->getActionName(json_decode(clean($_REQUEST["action"]), true));
print $this->_get_action_name(json_decode(clean($_REQUEST["action"]), true));
}
function editSave() {
$filter_id = clean($_REQUEST["id"]);
$enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"] ?? false));
$match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"]));
$match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false));
$inverse = checkbox_to_sql_bool(clean($_REQUEST["inverse"] ?? false));
$title = clean($_REQUEST["title"]);
@ -581,7 +505,7 @@ class Pref_Filters extends Handler_Protected {
$sth->execute([$enabled, $match_any_rule, $inverse, $title, $filter_id, $_SESSION['uid']]);
$this->saveRulesAndActions($filter_id);
$this->_save_rules_and_actions($filter_id);
$this->pdo->commit();
}
@ -596,8 +520,7 @@ class Pref_Filters extends Handler_Protected {
$sth->execute(array_merge($ids, [$_SESSION['uid']]));
}
private function saveRulesAndActions($filter_id)
{
private function _save_rules_and_actions($filter_id) {
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2_rules WHERE filter_id = ?");
$sth->execute([$filter_id]);
@ -674,11 +597,11 @@ class Pref_Filters extends Handler_Protected {
}
}
function add() {
$enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"]));
$match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"]));
function add () {
$enabled = checkbox_to_sql_bool(clean($_REQUEST["enabled"] ?? false));
$match_any_rule = checkbox_to_sql_bool(clean($_REQUEST["match_any_rule"] ?? false));
$title = clean($_REQUEST["title"]);
$inverse = checkbox_to_sql_bool(clean($_REQUEST["inverse"]));
$inverse = checkbox_to_sql_bool(clean($_REQUEST["inverse"] ?? false));
$this->pdo->beginTransaction();
@ -696,7 +619,7 @@ class Pref_Filters extends Handler_Protected {
if ($row = $sth->fetch()) {
$filter_id = $row['id'];
$this->saveRulesAndActions($filter_id);
$this->_save_rules_and_actions($filter_id);
}
$this->pdo->commit();
@ -710,257 +633,73 @@ class Pref_Filters extends Handler_Protected {
$filter_search = ($_SESSION["prefs_filter_search"] ?? "");
}
print "<div dojoType='dijit.layout.BorderContainer' gutters='false'>";
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>";
print "<div dojoType='fox.Toolbar'>";
print "<div style='float : right; padding-right : 4px;'>
<input dojoType=\"dijit.form.TextBox\" id=\"filter_search\" size=\"20\" type=\"search\"
value=\"$filter_search\">
<button dojoType=\"dijit.form.Button\" onclick=\"dijit.byId('filterTree').reload()\">".
__('Search')."</button>
</div>";
print "<div dojoType=\"fox.form.DropDownButton\">".
"<span>" . __('Select')."</span>";
print "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
print "<div onclick=\"dijit.byId('filterTree').model.setAllChecked(true)\"
dojoType=\"dijit.MenuItem\">".__('All')."</div>";
print "<div onclick=\"dijit.byId('filterTree').model.setAllChecked(false)\"
dojoType=\"dijit.MenuItem\">".__('None')."</div>";
print "</div></div>";
print "<button dojoType=\"dijit.form.Button\" onclick=\"return Filters.edit()\">".
__('Create filter')."</button> ";
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
print "<button dojoType=\"dijit.form.Button\" onclick=\"return dijit.byId('filterTree').joinSelectedFilters()\">".
__('Combine')."</button> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"return dijit.byId('filterTree').editSelectedFilter()\">".
__('Edit')."</button> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"return dijit.byId('filterTree').resetFilterOrder()\">".
__('Reset sort order')."</button> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"return dijit.byId('filterTree').removeSelectedFilters()\">".
__('Remove')."</button> ";
print "</div>"; # toolbar
print "</div>"; # toolbar-frame
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>";
<div style='float : right; padding-right : 4px;'>
<input dojoType="dijit.form.TextBox" id="filter_search" size="20" type="search"
value="<?= htmlspecialchars($filter_search) ?>">
<button dojoType="dijit.form.Button" onclick="dijit.byId('filterTree').reload()">
<?= __('Search') ?></button>
</div>
print "<div id='filterlistLoading'>
<img src='images/indicator_tiny.gif'>".
__("Loading, please wait...")."</div>";
<div dojoType="fox.form.DropDownButton">
<span><?= __('Select') ?></span>
<div dojoType="dijit.Menu" style="display: none;">
<div onclick="dijit.byId('filterTree').model.setAllChecked(true)"
dojoType="dijit.MenuItem"><?= __('All') ?></div>
<div onclick="dijit.byId('filterTree').model.setAllChecked(false)"
dojoType="dijit.MenuItem"><?= __('None') ?></div>
</div>
</div>
print "<div dojoType=\"fox.PrefFilterStore\" jsId=\"filterStore\"
url=\"backend.php?op=pref-filters&method=getfiltertree\">
</div>
<div dojoType=\"lib.CheckBoxStoreModel\" jsId=\"filterModel\" store=\"filterStore\"
query=\"{id:'root'}\" rootId=\"root\" rootLabel=\"Filters\"
childrenAttrs=\"items\" checkboxStrict=\"false\" checkboxAll=\"false\">
<button dojoType="dijit.form.Button" onclick="return Filters.edit()">
<?= __('Create filter') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').joinSelectedFilters()">
<?= __('Combine') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').resetFilterOrder()">
<?= __('Reset sort order') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').removeSelectedFilters()">
<?= __('Remove') ?></button>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<div dojoType="fox.PrefFilterStore" jsId="filterStore"
url="backend.php?op=pref-filters&method=getfiltertree">
</div>
<div dojoType="lib.CheckBoxStoreModel" jsId="filterModel" store="filterStore"
query="{id:'root'}" rootId="root" rootLabel="Filters"
childrenAttrs="items" checkboxStrict="false" checkboxAll="false">
</div>
<div dojoType="fox.PrefFilterTree" id="filterTree" dndController="dijit.tree.dndSource"
betweenThreshold="5" model="filterModel" openOnClick="true">
<script type="dojo/method" event="onClick" args="item">
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('FILTER:')) {
Filters.edit(bare_id);
}
</script>
</div>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefFilters") ?>
</div>
<div dojoType=\"fox.PrefFilterTree\" id=\"filterTree\"
dndController=\"dijit.tree.dndSource\"
betweenThreshold=\"5\"
model=\"filterModel\" openOnClick=\"true\">
<script type=\"dojo/method\" event=\"onLoad\" args=\"item\">
Element.hide(\"filterlistLoading\");
</script>
<script type=\"dojo/method\" event=\"onClick\" args=\"item\">
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('FILTER:')) {
Filters.edit(bare_id);
}
</script>
</div>";
print "</div>"; #pane
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefFilters");
print "</div>"; #container
<?php
}
function newrule() {
$rule = json_decode(clean($_REQUEST["rule"]), true);
if ($rule) {
$reg_exp = htmlspecialchars($rule["reg_exp"]);
$filter_type = $rule["filter_type"];
$feed_id = $rule["feed_id"];
$inverse_checked = isset($rule["inverse"]) ? "checked" : "";
} else {
$reg_exp = "";
$filter_type = 1;
$feed_id = ["0"];
$inverse_checked = "";
}
print "<form name='filter_new_rule_form' id='filter_new_rule_form' onsubmit='return false;'>";
$res = $this->pdo->query("SELECT id,description
FROM ttrss_filter_types WHERE id != 5 ORDER BY description");
$filter_types = array();
while ($line = $res->fetch()) {
$filter_types[$line["id"]] = __($line["description"]);
}
print "<header>".__("Match")."</header>";
print "<section>";
print "<textarea dojoType='fox.form.ValidationTextArea'
required='true' id='filterDlg_regExp'
ValidRegExp='true'
rows='4'
style='font-size : 14px; width : 490px; word-break: break-all'
name='reg_exp'>$reg_exp</textarea>";
function editrule() {
$feed_ids = explode(",", clean($_REQUEST["ids"]));
print "<div dojoType='dijit.Tooltip' id='filterDlg_regExp_tip' connectId='filterDlg_regExp' position='below'></div>";
print "<fieldset>";
print "<label class='checkbox'><input id='filterDlg_inverse' dojoType='dijit.form.CheckBox'
name='inverse' $inverse_checked/> ".
__("Inverse regular expression matching")."</label>";
print "</fieldset>";
print "<fieldset>";
print "<label style='display : inline'>". __("on field") . "</label> ";
print_select_hash("filter_type", $filter_type, $filter_types,
'dojoType="fox.form.Select"');
print "<label style='padding-left : 10px; display : inline'>" . __("in") . "</label> ";
print "</fieldset>";
print "<fieldset>";
print "<span id='filterDlg_feeds'>";
print_feed_multi_select("feed_id",
$feed_id,
'style="width : 500px; height : 300px" dojoType="dijit.form.MultiSelect"');
print "</span>";
print "</fieldset>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button' style='float : left' class='alt-info' onclick='window.open(\"https://tt-rss.org/wiki/ContentFilters\")'>
<i class='material-icons'>help</i> ".__("More info...")."</button>";
print "<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick='App.dialogOf(this).execute()'>".
($rule ? __("Save rule") : __('Add rule'))."</button> ";
print "<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>".
__('Cancel')."</button>";
print "</footer>";
print "</form>";
print json_encode([
"multiselect" => $this->_feed_multi_select("feed_id", $feed_ids, 'required="1" style="width : 100%; height : 300px" dojoType="fox.form.ValidationMultiSelect"')
]);
}
function newaction() {
$action = json_decode(clean($_REQUEST["action"]), true);
if ($action) {
$action_param = $action["action_param"];
$action_id = (int)$action["action_id"];
} else {
$action_param = "";
$action_id = 0;
}
print "<form name='filter_new_action_form' id='filter_new_action_form' onsubmit='return false;'>";
print "<header>".__("Perform Action")."</header>";
print "<section>";
print "<select name='action_id' dojoType='fox.form.Select'
onchange='Filters.filterDlgCheckAction(this)'>";
$res = $this->pdo->query("SELECT id,description FROM ttrss_filter_actions
ORDER BY name");
while ($line = $res->fetch()) {
$is_selected = ($line["id"] == $action_id) ? "selected='1'" : "";
printf("<option $is_selected value='%d'>%s</option>", $line["id"], __($line["description"]));
}
print "</select>";
$param_box_hidden = ($action_id == 7 || $action_id == 4 || $action_id == 6 || $action_id == 9) ?
"" : "display : none";
$param_hidden = ($action_id == 4 || $action_id == 6) ?
"" : "display : none";
$label_param_hidden = ($action_id == 7) ? "" : "display : none";
$plugin_param_hidden = ($action_id == 9) ? "" : "display : none";
print "<span id='filterDlg_paramBox' style=\"$param_box_hidden\">";
print " ";
//print " " . __("with parameters:") . " ";
print "<input dojoType='dijit.form.TextBox'
id='filterDlg_actionParam' style=\"$param_hidden\"
name='action_param' value=\"$action_param\">";
print_label_select("action_param_label", $action_param,
"id='filterDlg_actionParamLabel' style=\"$label_param_hidden\"
dojoType='fox.form.Select'");
$filter_actions = PluginHost::getInstance()->get_filter_actions();
$filter_action_hash = array();
foreach ($filter_actions as $fclass => $factions) {
foreach ($factions as $faction) {
$filter_action_hash[$fclass . ":" . $faction["action"]] =
$fclass . ": " . $faction["description"];
}
}
if (count($filter_action_hash) == 0) {
$filter_plugin_disabled = "disabled";
$filter_action_hash["no-data"] = __("No actions available");
} else {
$filter_plugin_disabled = "";
}
print_select_hash("filterDlg_actionParamPlugin", $action_param, $filter_action_hash,
"style=\"$plugin_param_hidden\" dojoType='fox.form.Select' $filter_plugin_disabled",
"action_param_plugin");
print "</span>";
print "&nbsp;"; // tiny layout hack
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick='App.dialogOf(this).execute()'>".
($action ? __("Save action") : __('Add action'))."</button> ";
print "<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>".
__('Cancel')."</button>";
print "</footer>";
print "</form>";
}
private function getFilterName($id) {
private function _get_name($id) {
$sth = $this->pdo->prepare(
"SELECT title,match_any_rule,f.inverse AS inverse,COUNT(DISTINCT r.id) AS num_rules,COUNT(DISTINCT a.id) AS num_actions
@ -989,7 +728,7 @@ class Pref_Filters extends Handler_Protected {
$actions = "";
if ($line = $sth->fetch()) {
$actions = $this->getActionName($line);
$actions = $this->_get_action_name($line);
$num_actions -= 1;
}
@ -1031,12 +770,12 @@ class Pref_Filters extends Handler_Protected {
$this->pdo->commit();
$this->optimizeFilter($base_id);
$this->_optimize($base_id);
}
}
private function optimizeFilter($id) {
private function _optimize($id) {
$this->pdo->beginTransaction();
@ -1090,4 +829,111 @@ class Pref_Filters extends Handler_Protected {
$this->pdo->commit();
}
private function _feed_multi_select($id, $default_ids = [],
$attributes = "", $include_all_feeds = true,
$root_id = null, $nest_level = 0) {
$pdo = Db::pdo();
$rv = "";
// print_r(in_array("CAT:6",$default_ids));
if (!$root_id) {
$rv .= "<select multiple=\true\" id=\"$id\" name=\"$id\" $attributes>";
if ($include_all_feeds) {
$is_selected = (in_array("0", $default_ids)) ? "selected=\"1\"" : "";
$rv .= "<option $is_selected value=\"0\">".__('All feeds')."</option>";
}
}
if (get_pref('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\"" : "";
$rv .= sprintf("<option $is_selected value='CAT:%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
$rv .= $this->_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"];
$rv .= sprintf("<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\"" : "";
$rv .= sprintf("<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"];
$rv .= sprintf("<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\"" : "";
$rv .= sprintf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
}
}
if (!$root_id) {
$rv .= "</select>";
}
return $rv;
}
}

@ -10,72 +10,12 @@ class Pref_Labels extends Handler_Protected {
function edit() {
$label_id = clean($_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color FROM ttrss_labels2 WHERE
id = ? AND owner_uid = ?");
$sth->execute([$label_id, $_SESSION['uid']]);
if ($line = $sth->fetch()) {
print_hidden("id", "$label_id");
print_hidden("op", "pref-labels");
print_hidden("method", "save");
print "<form onsubmit='return false;'>";
print "<header>".__("Caption")."</header>";
print "<section>";
$fg_color = $line['fg_color'];
$bg_color = $line['bg_color'] ? $line['bg_color'] : '#fff7d5';
print "<input style='font-size : 16px; color : $fg_color; background : $bg_color; transition : background 0.1s linear'
id='labelEdit_caption' name='caption' dojoType='dijit.form.ValidationTextBox'
required='true' value=\"".htmlspecialchars($line['caption'])."\">";
print "</section>";
print "<header>" . __("Colors") . "</header>";
print "<section>";
print "<table>";
print "<tr><th style='text-align : left'>".__("Foreground:")."</th><th style='text-align : left'>".__("Background:")."</th></tr>";
print "<tr><td style='padding-right : 10px'>";
print "<input dojoType='dijit.form.TextBox'
style='display : none' id='labelEdit_fgColor'
name='fg_color' value='$fg_color'>";
print "<input dojoType='dijit.form.TextBox'
style='display : none' id='labelEdit_bgColor'
name='bg_color' value='$bg_color'>";
print "<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='fg_color'>
dijit.byId('labelEdit_fgColor').attr('value', fg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({color: fg_color});
</script>
</div>";
print "</td><td>";
print "<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='bg_color'>
dijit.byId('labelEdit_bgColor').attr('value', bg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({backgroundColor: bg_color});
</script>
</div>";
print "</td></tr></table>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick='App.dialogOf(this).execute()'>".
__('Save')."</button>";
print "<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>".
__('Cancel')."</button>";
print "</footer>";
print "</form>";
if ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
print json_encode($line);
}
}
@ -197,7 +137,7 @@ class Pref_Labels extends Handler_Protected {
$sth->execute([$caption, $old_caption, $_SESSION['uid']]);
print clean($_REQUEST["value"]);
print clean($_REQUEST["caption"]);
} else {
print $old_caption;
}
@ -225,88 +165,64 @@ class Pref_Labels extends Handler_Protected {
$output = clean($_REQUEST["output"]);
if ($caption) {
if (Labels::create($caption)) {
if (!$output) {
print T_sprintf("Created label <b>%s</b>", htmlspecialchars($caption));
}
}
if ($output == "select") {
header("Content-Type: text/xml");
print "<rpc-reply><payload>";
print_label_select("select_label",
$caption, "");
print "</payload></rpc-reply>";
}
}
return;
}
function index() {
print "<div dojoType='dijit.layout.BorderContainer' gutters='false'>";
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>";
print "<div dojoType='fox.Toolbar'>";
print "<div dojoType='fox.form.DropDownButton'>".
"<span>" . __('Select')."</span>";
print "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
print "<div onclick=\"dijit.byId('labelTree').model.setAllChecked(true)\"
dojoType=\"dijit.MenuItem\">".__('All')."</div>";
print "<div onclick=\"dijit.byId('labelTree').model.setAllChecked(false)\"
dojoType=\"dijit.MenuItem\">".__('None')."</div>";
print "</div></div>";
print"<button dojoType=\"dijit.form.Button\" onclick=\"CommonDialogs.addLabel()\">".
__('Create label')."</button dojoType=\"dijit.form.Button\"> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"dijit.byId('labelTree').removeSelected()\">".
__('Remove')."</button dojoType=\"dijit.form.Button\"> ";
print "<button dojoType=\"dijit.form.Button\" onclick=\"dijit.byId('labelTree').resetColors()\">".
__('Clear colors')."</button dojoType=\"dijit.form.Button\">";
print "</div>"; #toolbar
print "</div>"; #pane
print "<div style='padding : 0px' dojoType=\"dijit.layout.ContentPane\" region=\"center\">";
print "<div id=\"labellistLoading\">
<img src='images/indicator_tiny.gif'>".
__("Loading, please wait...")."</div>";
print "<div dojoType=\"dojo.data.ItemFileWriteStore\" jsId=\"labelStore\"
url=\"backend.php?op=pref-labels&method=getlabeltree\">
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="dijit.byId('labelTree').model.setAllChecked(true)"
dojoType='dijit.MenuItem'><?=('All') ?></div>
<div onclick="dijit.byId('labelTree').model.setAllChecked(false)"
dojoType='dijit.MenuItem'><?=('None') ?></div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='CommonDialogs.addLabel()'>
<?=('Create label') ?></button dojoType='dijit.form.Button'>
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').removeSelected()">
<?=('Remove') ?></button dojoType='dijit.form.Button'>
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').resetColors()">
<?=('Clear colors') ?></button dojoType='dijit.form.Button'>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<div dojoType='dojo.data.ItemFileWriteStore' jsId='labelStore'
url='backend.php?op=pref-labels&method=getlabeltree'>
</div>
<div dojoType='lib.CheckBoxStoreModel' jsId='labelModel' store='labelStore'
query="{id:'root'}" rootId='root'
childrenAttrs='items' checkboxStrict='false' checkboxAll='false'>
</div>
<div dojoType='fox.PrefLabelTree' id='labelTree' model='labelModel' openOnClick='true'>
<script type='dojo/method' event='onClick' args='item'>
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('LABEL:')) {
dijit.byId('labelTree').editLabel(bare_id);
}
</script>
</div>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefLabels") ?>
</div>
<div dojoType=\"lib.CheckBoxStoreModel\" jsId=\"labelModel\" store=\"labelStore\"
query=\"{id:'root'}\" rootId=\"root\"
childrenAttrs=\"items\" checkboxStrict=\"false\" checkboxAll=\"false\">
</div>
<div dojoType=\"fox.PrefLabelTree\" id=\"labelTree\"
model=\"labelModel\" openOnClick=\"true\">
<script type=\"dojo/method\" event=\"onLoad\" args=\"item\">
Element.hide(\"labellistLoading\");
</script>
<script type=\"dojo/method\" event=\"onClick\" args=\"item\">
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('LABEL:')) {
dijit.byId('labelTree').editLabel(bare_id);
}
</script>
</div>";
print "</div>"; #pane
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefLabels");
print "</div>"; #container
<?php
}
}

File diff suppressed because it is too large Load Diff

@ -1,20 +1,9 @@
<?php
class Pref_System extends Handler_Protected {
class Pref_System extends Handler_Administrative {
private $log_page_limit = 15;
function before($method) {
if (parent::before($method)) {
if ($_SESSION["access_level"] < 10) {
print __("Your access level is insufficient to open this tab.");
return false;
}
return true;
}
return false;
}
function csrf_ignore($method) {
$csrf_ignored = array("index");
@ -25,7 +14,16 @@ class Pref_System extends Handler_Protected {
$this->pdo->query("DELETE FROM ttrss_error_log");
}
private function log_viewer(int $page, int $severity) {
function getphpinfo() {
ob_start();
phpinfo();
$info = ob_get_contents();
ob_end_clean();
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', (string)$info);
}
private function _log_viewer(int $page, int $severity) {
$errno_values = [];
switch ($severity) {
@ -62,125 +60,121 @@ class Pref_System extends Handler_Protected {
$total_pages = 0;
}
print "<div dojoType='dijit.layout.BorderContainer' gutters='false'>";
print "<div region='top' dojoType='fox.Toolbar'>";
print "<button dojoType='dijit.form.Button'
onclick='Helpers.EventLog.refresh()'>".__('Refresh')."</button>";
$prev_page_disabled = $page <= 0 ? "disabled" : "";
print "<button dojoType='dijit.form.Button' $prev_page_disabled
onclick='Helpers.EventLog.prevPage()'>".__('&lt;&lt;')."</button>";
print "<button dojoType='dijit.form.Button' disabled>".T_sprintf('Page %d of %d', $page+1, $total_pages+1)."</button>";
$next_page_disabled = $page >= $total_pages ? "disabled" : "";
print "<button dojoType='dijit.form.Button' $next_page_disabled
onclick='Helpers.EventLog.nextPage()'>".__('&gt;&gt;')."</button>";
print "<button dojoType='dijit.form.Button'
onclick='Helpers.EventLog.clear()'>".__('Clear')."</button>";
print "<div class='pull-right'>";
print __("Severity:") . " ";
print_select_hash("severity", $severity,
[
E_USER_ERROR => __("Errors"),
E_USER_WARNING => __("Warnings"),
E_USER_NOTICE => __("Everything")
], 'dojoType="fox.form.Select" onchange="Helpers.EventLog.refresh()"');
print "</div>"; # pull-right
print "</div>"; # toolbar
print '<div style="padding : 0px" dojoType="dijit.layout.ContentPane" region="center">';
print "<table width='100%' class='event-log'>";
print "<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>";
$sth = $this->pdo->prepare("SELECT
errno, errstr, filename, lineno, created_at, login, context
FROM
ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
WHERE
$errno_filter_qpart
ORDER BY
ttrss_error_log.id DESC
LIMIT $limit OFFSET $offset");
$sth->execute($errno_values);
while ($line = $sth->fetch()) {
print "<tr>";
foreach ($line as $k => $v) {
$line[$k] = htmlspecialchars($v);
}
print "<td class='errno'>" . Logger::$errornames[$line["errno"]] . " (" . $line["errno"] . ")</td>";
print "<td class='filename'>" . $line["filename"] . ":" . $line["lineno"] . "</td>";
print "<td class='errstr'>" . $line["errstr"] . "\n" . $line["context"] . "</td>";
print "<td class='login'>" . $line["login"] . "</td>";
print "<td class='timestamp'>" .
TimeHelper::make_local_datetime($line["created_at"], false) . "</td>";
print "</tr>";
}
print "</table>";
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div region='top' dojoType='fox.Toolbar'>
<button dojoType='dijit.form.Button' onclick='Helpers.EventLog.refresh()'>
<?= __('Refresh') ?>
</button>
<button dojoType='dijit.form.Button' <?= ($page <= 0 ? "disabled" : "") ?>
onclick='Helpers.EventLog.prevPage()'>
<?= __('&lt;&lt;') ?>
</button>
<button dojoType='dijit.form.Button' disabled>
<?= T_sprintf('Page %d of %d', $page+1, $total_pages+1) ?>
</button>
<button dojoType='dijit.form.Button' <?= ($page >= $total_pages ? "disabled" : "") ?>
onclick='Helpers.EventLog.nextPage()'>
<?= __('&gt;&gt;') ?>
</button>
<button dojoType='dijit.form.Button'
onclick='Helpers.EventLog.clear()'>
<?= __('Clear') ?>
</button>
<div class='pull-right'>
<label><?= __("Severity:") ?></label>
<?= \Controls\select_hash("severity", $severity,
[
E_USER_ERROR => __("Errors"),
E_USER_WARNING => __("Warnings"),
E_USER_NOTICE => __("Everything")
], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?>
</div>
</div>
<div style="padding : 0px" dojoType="dijit.layout.ContentPane" region="center">
<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>
<?php
$sth = $this->pdo->prepare("SELECT
errno, errstr, filename, lineno, created_at, login, context
FROM
ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
WHERE
$errno_filter_qpart
ORDER BY
ttrss_error_log.id DESC
LIMIT $limit OFFSET $offset");
$sth->execute($errno_values);
while ($line = $sth->fetch()) {
foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v); }
?>
<tr>
<td class='errno'>
<?= Logger::$errornames[$line["errno"]] . " (" . $line["errno"] . ")" ?>
</td>
<td class='filename'><?= $line["filename"] . ":" . $line["lineno"] ?></td>
<td class='errstr'><?= $line["errstr"] . "\n" . $line["context"] ?></td>
<td class='login'><?= $line["login"] ?></td>
<td class='timestamp'>
<?= TimeHelper::make_local_datetime($line["created_at"], false) ?>
</td>
</tr>
<?php } ?>
</table>
</div>
</div>
<?php
}
function index() {
$severity = (int) ($_REQUEST["severity"] ?? E_USER_WARNING);
$page = (int) ($_REQUEST["page"] ?? 0);
print "<div dojoType='dijit.layout.AccordionContainer' region='center'>";
print "<div dojoType='dijit.layout.AccordionPane' style='padding : 0'
title='<i class=\"material-icons\">report</i> ".__('Event Log')."'>";
if (LOG_DESTINATION == "sql") {
$this->log_viewer($page, $severity);
} else {
print_notice("Please set LOG_DESTINATION to 'sql' in config.php to enable database logging.");
}
print "</div>"; # content pane
print "</div>"; # container
print "</div>"; # accordion pane
print "<div dojoType='dijit.layout.AccordionPane'
title='<i class=\"material-icons\">info</i> ".__('PHP Information')."'>";
ob_start();
phpinfo();
$info = ob_get_contents();
ob_end_clean();
print "<div class='phpinfo'>";
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', $info);
print "</div>";
print "</div>"; # accordion pane
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem");
print "</div>"; #container
?>
<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") {
$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 dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">info</i> <?= __('PHP Information') ?>'>
<script type='dojo/method' event='onSelected' args='evt'>
if (this.domNode.querySelector('.loading'))
window.setTimeout(() => {
xhr.post("backend.php", {op: 'pref-system', method: 'getphpinfo'}, (reply) => {
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
});
}, 200);
</script>
<span class='loading'><?= __("Loading, please wait...") ?></span>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?>
</div>
<?php
}
}

@ -1,18 +1,7 @@
<?php
class Pref_Users extends Handler_Protected {
function before($method) {
if (parent::before($method)) {
if ($_SESSION["access_level"] < 10) {
print __("Your access level is insufficient to open this tab.");
return false;
}
return true;
}
return false;
}
class Pref_Users extends Handler_Administrative {
function csrf_ignore($method) {
$csrf_ignored = array("index", "userdetails");
$csrf_ignored = array("index");
return array_search($method, $csrf_ignored) !== false;
}
@ -20,105 +9,17 @@ class Pref_Users extends Handler_Protected {
function edit() {
global $access_level_names;
print "<form id='user_edit_form' onsubmit='return false' dojoType='dijit.form.Form'>";
print '<div dojoType="dijit.layout.TabContainer" style="height : 400px">
<div dojoType="dijit.layout.ContentPane" title="'.__('Edit user').'">';
//print "<form id=\"user_edit_form\" onsubmit='return false' dojoType=\"dijit.form.Form\">";
$id = (int) clean($_REQUEST["id"]);
print_hidden("id", "$id");
print_hidden("op", "pref-users");
print_hidden("method", "editSave");
$id = (int)clean($_REQUEST["id"]);
$sth = $this->pdo->prepare("SELECT * FROM ttrss_users WHERE id = ?");
$sth = $this->pdo->prepare("SELECT id, login, access_level, email FROM ttrss_users WHERE id = ?");
$sth->execute([$id]);
if ($row = $sth->fetch()) {
$login = $row["login"];
$access_level = $row["access_level"];
$email = $row["email"];
$sel_disabled = ($id == $_SESSION["uid"] || $login == "admin") ? "disabled" : "";
print "<header>".__("User")."</header>";
print "<section>";
if ($sel_disabled) {
print_hidden("login", "$login");
}
print "<fieldset>";
print "<label>" . __("Login:") . "</label>";
print "<input style='font-size : 16px'
dojoType='dijit.form.ValidationTextBox' required='1'
$sel_disabled name='login' value=\"$login\">";
print "</fieldset>";
print "</section>";
print "<header>".__("Authentication")."</header>";
print "<section>";
print "<fieldset>";
print "<label>" . __('Access level: ') . "</label> ";
if (!$sel_disabled) {
print_select_hash("access_level", $access_level, $access_level_names,
"dojoType=\"fox.form.Select\" $sel_disabled");
} else {
print_select_hash("", $access_level, $access_level_names,
"dojoType=\"fox.form.Select\" $sel_disabled");
print_hidden("access_level", "$access_level");
}
print "</fieldset>";
print "<fieldset>";
print "<label>" . __("New password:") . "</label> ";
print "<input dojoType='dijit.form.TextBox' type='password' size='20' placeholder='Change password'
name='password'>";
print "</fieldset>";
print "</section>";
print "<header>".__("Options")."</header>";
print "<section>";
print "<fieldset>";
print "<label>" . __("E-mail:") . "</label> ";
print "<input dojoType='dijit.form.TextBox' size='30' name='email'
value=\"$email\">";
print "</fieldset>";
print "</section>";
print "</table>";
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
print json_encode([
"user" => $row,
"access_level_names" => $access_level_names
]);
}
print '</div>'; #tab
print "<div href=\"backend.php?op=pref-users&method=userdetails&id=$id\"
dojoType=\"dijit.layout.ContentPane\" title=\"".__('User details')."\">";
print '</div>';
print '</div>';
print "<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick='App.dialogOf(this).execute()'>".
__('Save')."</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>".
__('Cancel')."</button>
</footer>";
print "</form>";
return;
}
function userdetails() {
@ -135,7 +36,6 @@ class Pref_Users extends Handler_Protected {
$sth->execute([$id]);
if ($row = $sth->fetch()) {
print "<table width='100%'>";
$last_login = TimeHelper::make_local_datetime(
$row["last_login"], true);
@ -145,47 +45,62 @@ class Pref_Users extends Handler_Protected {
$stored_articles = $row["stored_articles"];
print "<tr><td>".__('Registered')."</td><td>$created</td></tr>";
print "<tr><td>".__('Last logged in')."</td><td>$last_login</td></tr>";
$sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds
WHERE owner_uid = ?");
$sth->execute([$id]);
$row = $sth->fetch();
$num_feeds = $row["num_feeds"];
print "<tr><td>".__('Subscribed feeds count')."</td><td>$num_feeds</td></tr>";
print "<tr><td>".__('Stored articles')."</td><td>$stored_articles</td></tr>";
print "</table>";
$num_feeds = $row["num_feeds"];
print "<h1>".__('Subscribed feeds')."</h1>";
?>
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$id]);
<fieldset>
<label><?= __('Registered') ?>:</label>
<?= $created ?>
</fieldset>
print "<ul class=\"panel panel-scrollable list list-unstyled\">";
<fieldset>
<label><?= __('Last logged in') ?>:</label>
<?= $last_login ?>
</fieldset>
while ($line = $sth->fetch()) {
<fieldset>
<label><?= __('Subscribed feeds') ?>:</label>
<?= $num_feeds ?>
</fieldset>
$icon_file = ICONS_URL."/".$line["id"].".ico";
<fieldset>
<label><?= __('Stored articles') ?>:</label>
<?= $stored_articles ?>
</fieldset>
if (file_exists($icon_file) && filesize($icon_file) > 0) {
$feed_icon = "<img class=\"icon\" src=\"$icon_file\">";
} else {
$feed_icon = "<img class=\"icon\" src=\"images/blank_icon.gif\">";
}
<?php
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$id]);
?>
print "<li>$feed_icon&nbsp;<a href=\"".$line["site_url"]."\">".$line["title"]."</a></li>";
<ul class="panel panel-scrollable list list-unstyled">
<?php while ($row = $sth->fetch()) { ?>
<li>
<?php
$icon_file = Config::get(Config::ICONS_URL) . "/" . $row["id"] . ".ico";
$icon = file_exists($icon_file) ? $icon_file : "images/blank_icon.gif";
?>
}
<img class="icon" src="<?= $icon_file ?>">
print "</ul>";
<a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>">
<?= htmlspecialchars($row["title"]) ?>
</a>
</li>
<?php } ?>
</ul>
<?php
} else {
print "<h1>".__('User not found')."</h1>";
print_error(__('User not found'));
}
}
@ -197,6 +112,12 @@ class Pref_Users extends Handler_Protected {
$email = clean($_REQUEST["email"]);
$password = clean($_REQUEST["password"]);
// no blank usernames
if (!$login) return;
// forbid renaming admin
if ($uid == 1) $login = "admin";
if ($password) {
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$pwd_hash = encrypt_password($password, $salt, true);
@ -246,67 +167,25 @@ class Pref_Users extends Handler_Protected {
if ($new_uid = UserHelper::find_user_by_login($login)) {
$new_uid = $row['id'];
print T_sprintf("Added user %s with password %s",
$login, $tmp_user_pwd);
$this->initialize_user($new_uid);
} else {
print T_sprintf("Could not create user %s", $login);
}
} else {
print T_sprintf("User %s already exists.", $login);
}
}
static function resetUserPassword($uid, $format_output = false) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT login FROM ttrss_users WHERE id = ?");
$sth->execute([$uid]);
if ($row = $sth->fetch()) {
$login = $row["login"];
$new_salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$tmp_user_pwd = make_password();
$pwd_hash = encrypt_password($tmp_user_pwd, $new_salt, true);
$sth = $pdo->prepare("UPDATE ttrss_users
SET pwd_hash = ?, salt = ?, otp_enabled = false
WHERE id = ?");
$sth->execute([$pwd_hash, $new_salt, $uid]);
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
if ($format_output)
print_notice($message);
else
print $message;
}
}
function resetPass() {
$uid = clean($_REQUEST["id"]);
self::resetUserPassword($uid);
UserHelper::reset_password(clean($_REQUEST["id"]));
}
function index() {
global $access_level_names;
print "<div dojoType='dijit.layout.BorderContainer' gutters='false'>";
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>";
print "<div dojoType='fox.Toolbar'>";
$user_search = clean($_REQUEST["search"] ?? "");
if (array_key_exists("search", $_REQUEST)) {
@ -315,146 +194,111 @@ class Pref_Users extends Handler_Protected {
$user_search = ($_SESSION["prefs_user_search"] ?? "");
}
print "<div style='float : right; padding-right : 4px;'>
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
value=\"$user_search\">
<button dojoType='dijit.form.Button' onclick='Users.reload()'>".
__('Search')."</button>
</div>";
$sort = clean($_REQUEST["sort"] ?? "");
if (!$sort || $sort == "undefined") {
$sort = "login";
}
print "<div dojoType='fox.form.DropDownButton'>".
"<span>" . __('Select')."</span>";
print "<div dojoType='dijit.Menu' style='display: none'>";
print "<div onclick=\"Tables.select('users-list', true)\"
dojoType='dijit.MenuItem'>".__('All')."</div>";
print "<div onclick=\"Tables.select('users-list', false)\"
dojoType='dijit.MenuItem'>".__('None')."</div>";
print "</div></div>";
print "<button dojoType='dijit.form.Button' onclick='Users.add()'>".__('Create user')."</button>";
print "
<button dojoType='dijit.form.Button' onclick='Users.editSelected()'>".
__('Edit')."</button dojoType=\"dijit.form.Button\">
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>".
__('Remove')."</button dojoType=\"dijit.form.Button\">
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>".
__('Reset password')."</button dojoType=\"dijit.form.Button\">";
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar");
print "</div>"; #toolbar
print "</div>"; #pane
print "<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>";
$sort = $this->validate_field($sort,
$sort = $this->_validate_field($sort,
["login", "access_level", "created", "num_feeds", "created", "last_login"], "login");
if ($sort != "login") $sort = "$sort DESC";
$sth = $this->pdo->prepare("SELECT
tu.id,
login,access_level,email,
".SUBSTRING_FOR_DATE."(last_login,1,16) as last_login,
".SUBSTRING_FOR_DATE."(created,1,16) as created,
(SELECT COUNT(id) FROM ttrss_feeds WHERE owner_uid = tu.id) AS num_feeds
FROM
ttrss_users tu
WHERE
(:search = '' OR login LIKE :search) AND tu.id > 0
ORDER BY $sort");
$sth->execute([":search" => $user_search ? "%$user_search%" : ""]);
print "<table width='100%' class='users-list' id='users-list'>";
print "<tr class='title'>
<td align='center' width='5%'>&nbsp;</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>";
$lnum = 0;
while ($line = $sth->fetch()) {
$uid = $line["id"];
print "<tr data-row-id='$uid' onclick='Users.edit($uid)'>";
$line["login"] = htmlspecialchars($line["login"]);
$line["created"] = TimeHelper::make_local_datetime($line["created"], false);
$line["last_login"] = TimeHelper::make_local_datetime($line["last_login"], false);
print "<td align='center'><input onclick='Tables.onRowChecked(this); event.stopPropagation();'
dojoType='dijit.form.CheckBox' type='checkbox'></td>";
print "<td title='".__('Click to edit')."'><i class='material-icons'>person</i> " . $line["login"] . "</td>";
print "<td>" . $access_level_names[$line["access_level"]] . "</td>";
print "<td>" . $line["num_feeds"] . "</td>";
print "<td>" . $line["created"] . "</td>";
print "<td>" . $line["last_login"] . "</td>";
print "</tr>";
++$lnum;
}
print "</table>";
if ($lnum == 0) {
if (!$user_search) {
print_warning(__('No users defined.'));
} else {
print_warning(__('No matching users found.'));
}
}
print "</div>"; #pane
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers");
print "</div>"; #container
}
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
<div style='float : right'>
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
value="<?= htmlspecialchars($user_search) ?>">
<button dojoType='dijit.form.Button' onclick='Users.reload()'>
<?= __('Search') ?>
</button>
</div>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Tables.select('users-list', true)"
dojoType='dijit.MenuItem'><?= __('All') ?></div>
<div onclick="Tables.select('users-list', false)"
dojoType='dijit.MenuItem'><?= __('None') ?></div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='Users.add()'>
<?= __('Create user') ?>
</button>
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>
<?= __('Remove') ?>
</button>
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>
<?= __('Reset password') ?>
</button>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<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>
<?php
$sth = $this->pdo->prepare("SELECT
tu.id,
login,access_level,email,
".SUBSTRING_FOR_DATE."(last_login,1,16) as last_login,
".SUBSTRING_FOR_DATE."(created,1,16) as created,
(SELECT COUNT(id) FROM ttrss_feeds WHERE owner_uid = tu.id) AS num_feeds
FROM
ttrss_users tu
WHERE
(:search = '' OR login LIKE :search) AND tu.id > 0
ORDER BY $sort");
$sth->execute([":search" => $user_search ? "%$user_search%" : ""]);
while ($row = $sth->fetch()) { ?>
<tr data-row-id='<?= $row["id"] ?>' onclick='Users.edit(<?= $row["id"] ?>)' title="<?= __('Click to edit') ?>">
<td align='center'>
<input onclick='Tables.onRowChecked(this); event.stopPropagation();'
dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td><i class='material-icons'>person</i> <?= htmlspecialchars($row["login"]) ?></td>
<td><?= $access_level_names[$row["access_level"]] ?></td>
<td><?= $row["num_feeds"] ?></td>
<td><?= TimeHelper::make_local_datetime($row["created"], false) ?></td>
<td><?= TimeHelper::make_local_datetime($row["last_login"], false) ?></td>
</tr>
<?php } ?>
</table>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?>
</div>
<?php
}
function validate_field($string, $allowed, $default = "") {
private function _validate_field($string, $allowed, $default = "") {
if (in_array($string, $allowed))
return $string;
else
return $default;
}
// this is called after user is created to initialize default feeds, labels
// or whatever else
// user preferences are checked on every login, not here
static function initialize_user($uid) {
$pdo = Db::pdo();
$sth = $pdo->prepare("insert into ttrss_feeds (owner_uid,title,feed_url)
values (?, 'Tiny Tiny RSS: Forum',
'https://tt-rss.org/forum/rss.php')");
$sth->execute([$uid]);
}
static function logout_user() {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time()-42000, '/');
}
session_commit();
}
}

@ -1,96 +1,11 @@
<?php
class RPC extends Handler_Protected {
function csrf_ignore($method) {
$csrf_ignored = array("completelabels", "saveprofile");
/*function csrf_ignore($method) {
$csrf_ignored = array("completelabels");
return array_search($method, $csrf_ignored) !== false;
}
function setprofile() {
$_SESSION["profile"] = (int) clean($_REQUEST["id"]);
// default value
if (!$_SESSION["profile"]) $_SESSION["profile"] = null;
}
function remprofiles() {
$ids = explode(",", clean($_REQUEST["ids"]));
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']]);
}
}
}
// Silent
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']]);
if (!$sth->fetch()) {
$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']]);
if ($row = $sth->fetch()) {
$profile_id = $row['id'];
if ($profile_id) {
Pref_Prefs::initialize_user_prefs($_SESSION["uid"], $profile_id);
}
}
}
$this->pdo->commit();
}
}
function saveprofile() {
$id = clean($_REQUEST["id"]);
$title = clean($_REQUEST["value"]);
if ($id == 0) {
print __("Default profile");
return;
}
if ($title) {
$sth = $this->pdo->prepare("UPDATE ttrss_settings_profiles
SET title = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$title, $id, $_SESSION['uid']]);
print $title;
}
}
function addfeed() {
$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_to_feed($feed, $cat, $login, $pass);
print json_encode(array("result" => $rc));
}
}*/
function togglepref() {
$key = clean($_REQUEST["key"]);
@ -131,7 +46,7 @@ class RPC extends Handler_Protected {
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute(array_merge($ids, [$_SESSION['uid']]));
Article::purge_orphans();
Article::_purge_orphans();
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@ -149,67 +64,100 @@ class RPC extends Handler_Protected {
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"] ?? []));
// @phpstan-ignore-next-line
$counters = is_array($feed_ids) ? Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$reply = [
'counters' => Counters::getAllCounters(),
'counters' => $counters,
'seq' => $seq
];
if ($seq % 2 == 0)
$reply['runtime-info'] = $this->make_runtime_info();
print json_encode($reply);
}
/* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */
function catchupSelected() {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
Article::catchupArticlesById($ids, $cmode);
if (count($ids) > 0)
Article::_catchup_by_id($ids, $cmode);
print json_encode(array("message" => "UPDATE_COUNTERS", "ids" => $ids));
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function markSelected() {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
$this->markArticlesById($ids, $cmode);
if (count($ids) > 0)
$this->markArticlesById($ids, $cmode);
print json_encode(array("message" => "UPDATE_COUNTERS"));
print json_encode(["message" => "UPDATE_COUNTERS", "feeds" => Article::_feeds_of($ids)]);
}
function publishSelected() {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
$this->publishArticlesById($ids, $cmode);
if (count($ids) > 0)
$this->publishArticlesById($ids, $cmode);
print json_encode(array("message" => "UPDATE_COUNTERS"));
print json_encode(["message" => "UPDATE_COUNTERS", "feeds" => Article::_feeds_of($ids)]);
}
function sanityCheck() {
$_SESSION["hasAudio"] = clean($_REQUEST["hasAudio"]) === "true";
$_SESSION["hasSandbox"] = clean($_REQUEST["hasSandbox"]) === "true";
$_SESSION["hasMp3"] = clean($_REQUEST["hasMp3"]) === "true";
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
$reply = array();
$error = Errors::E_SUCCESS;
if (get_schema_version(true) != SCHEMA_VERSION) {
$error = Errors::E_SCHEMA_MISMATCH;
}
$reply['error'] = sanity_check();
if ($error == Errors::E_SUCCESS) {
$reply = [];
if ($reply['error']['code'] == 0) {
$reply['init-params'] = $this->make_init_params();
$reply['runtime-info'] = $this->make_runtime_info();
}
print json_encode($reply);
print json_encode($reply);
} else {
print Errors::to_json($error);
}
}
function completeLabels() {
/*function completeLabels() {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT caption FROM
@ -224,19 +172,19 @@ class RPC extends Handler_Protected {
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']);
$mode = clean($_REQUEST['mode'] ?? '');
$search_query = clean($_REQUEST['search_query']);
$search_lang = clean($_REQUEST['search_lang']);
Feeds::catchup_feed($feed_id, $is_cat, false, $mode, [$search_query, $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::getAllCounters()]);
print json_encode(['counters' => Counters::get_all()]);
//print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@ -244,8 +192,9 @@ class RPC extends Handler_Protected {
function setpanelmode() {
$wide = (int) clean($_REQUEST["wide"]);
// FIXME should this use SESSION_COOKIE_LIFETIME and be renewed periodically?
setcookie("ttrss_widescreen", (string)$wide,
time() + COOKIE_LIFETIME_LONG);
time() + 86400*365);
print json_encode(array("wide" => $wide));
}
@ -253,7 +202,7 @@ class RPC extends Handler_Protected {
static function updaterandomfeed_real() {
// Test if the feed need a update (update interval exceded).
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$update_limit_qpart = "AND ((
ttrss_feeds.update_interval = 0
AND ttrss_feeds.last_updated < NOW() - CAST((ttrss_user_prefs.value || ' minutes') AS INTERVAL)
@ -278,7 +227,7 @@ class RPC extends Handler_Protected {
}
// Test if feed is currently being updated by another process.
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < NOW() - INTERVAL '5 minutes')";
} else {
$updstart_thresh_qpart = "AND (ttrss_feeds.last_update_started IS NULL OR ttrss_feeds.last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
@ -324,7 +273,7 @@ class RPC extends Handler_Protected {
}
// Purge orphans and cleanup tags
Article::purge_orphans();
Article::_purge_orphans();
//cleanup_tags(14, 50000);
if ($num_updated > 0) {
@ -382,23 +331,6 @@ class RPC extends Handler_Protected {
$sth->execute(array_merge($ids, [$_SESSION['uid']]));
}
function getlinktitlebyid() {
$id = clean($_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT link, title FROM ttrss_entries, ttrss_user_entries
WHERE ref_id = ? AND ref_id = id AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$link = $row['link'];
$title = $row['title'];
echo json_encode(array("link" => $link, "title" => $title));
} else {
echo json_encode(array("error" => "ARTICLE_NOT_FOUND"));
}
}
function log() {
$msg = clean($_REQUEST['msg']);
$file = basename(clean($_REQUEST['file']));
@ -410,10 +342,7 @@ class RPC extends Handler_Protected {
$msg, 'client-js:' . $file, $line, $context);
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
} else {
echo json_encode(array("error" => "MESSAGE_NOT_FOUND"));
}
}
function checkforupdates() {
@ -424,7 +353,7 @@ class RPC extends Handler_Protected {
get_version($git_commit, $git_timestamp);
if (defined('CHECK_FOR_UPDATES') && CHECK_FOR_UPDATES && $_SESSION["access_level"] >= 10 && $git_timestamp) {
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) {
@ -455,9 +384,9 @@ class RPC extends Handler_Protected {
}
$params["safe_mode"] = !empty($_SESSION["safe_mode"]);
$params["check_for_updates"] = CHECK_FOR_UPDATES;
$params["icons_url"] = ICONS_URL;
$params["cookie_lifetime"] = SESSION_COOKIE_LIFETIME;
$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("_DEFAULT_VIEW_MODE");
$params["default_view_limit"] = (int) get_pref("_DEFAULT_VIEW_LIMIT");
$params["default_view_order_by"] = get_pref("_DEFAULT_VIEW_ORDER_BY");
@ -486,16 +415,11 @@ class RPC extends Handler_Protected {
$params["self_url_prefix"] = get_self_url_prefix();
$params["max_feed_id"] = (int) $max_feed_id;
$params["num_feeds"] = (int) $num_feeds;
$params["hotkeys"] = $this->get_hotkeys_map();
$params["widescreen"] = (int) ($_COOKIE["ttrss_widescreen"] ?? 0);
$params['simple_update'] = SIMPLE_UPDATE_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_labels($_SESSION["uid"]);
$params["labels"] = Labels::get_all($_SESSION["uid"]);
return $params;
}
@ -526,10 +450,10 @@ class RPC extends Handler_Protected {
$data["max_feed_id"] = (int) $max_feed_id;
$data["num_feeds"] = (int) $num_feeds;
$data['cdm_expanded'] = get_pref('CDM_EXPANDED');
$data["labels"] = Labels::get_all_labels($_SESSION["uid"]);
$data["labels"] = Labels::get_all($_SESSION["uid"]);
if (LOG_DESTINATION == 'sql' && $_SESSION['access_level'] >= 10) {
if (DB_TYPE == 'pgsql') {
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)";
@ -538,7 +462,7 @@ class RPC extends Handler_Protected {
$sth = $pdo->prepare("SELECT COUNT(id) AS cid
FROM ttrss_error_log
WHERE
errno != 1024 AND
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();
@ -548,13 +472,13 @@ class RPC extends Handler_Protected {
}
}
if (file_exists(LOCK_DIRECTORY . "/update_daemon.lock")) {
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(LOCK_DIRECTORY . "/update_daemon.stamp");
$stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp");
if ($stamp) {
$stamp_delta = time() - $stamp;
@ -737,4 +661,73 @@ class RPC extends Handler_Protected {
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
}
}

@ -34,9 +34,9 @@ class RSSUtils {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds WHERE id = ?");
// check icon files once every CACHE_MAX_DAYS days
$icon_files = array_filter(glob(ICONS_DIR . "/*.ico"),
function($f) { return filemtime($f) < time() - 86400*CACHE_MAX_DAYS; });
// check icon files once every Config::get(Config::CACHE_MAX_DAYS) days
$icon_files = array_filter(glob(Config::get(Config::ICONS_DIR) . "/*.ico"),
function($f) { return filemtime($f) < time() - 86400 * Config::get(Config::CACHE_MAX_DAYS); });
foreach ($icon_files as $icon) {
$feed_id = basename($icon, ".ico");
@ -52,26 +52,28 @@ class RSSUtils {
}
}
static function update_daemon_common($limit = DAEMON_FEED_LIMIT, $options = []) {
static function update_daemon_common($limit = null, $options = []) {
$schema_version = get_schema_version();
if (!$limit) $limit = Config::get(Config::DAEMON_FEED_LIMIT);
if ($schema_version != SCHEMA_VERSION) {
die("Schema version is wrong, please upgrade the database.\n");
}
$pdo = Db::pdo();
if (!SINGLE_USER_MODE && DAEMON_UPDATE_LOGIN_LIMIT > 0) {
if (DB_TYPE == "pgsql") {
$login_thresh_qpart = "AND ttrss_users.last_login >= NOW() - INTERVAL '".DAEMON_UPDATE_LOGIN_LIMIT." days'";
if (!Config::get(Config::SINGLE_USER_MODE) && Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT) > 0) {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$login_thresh_qpart = "AND ttrss_users.last_login >= NOW() - INTERVAL '".Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT)." days'";
} else {
$login_thresh_qpart = "AND ttrss_users.last_login >= DATE_SUB(NOW(), INTERVAL ".DAEMON_UPDATE_LOGIN_LIMIT." DAY)";
$login_thresh_qpart = "AND ttrss_users.last_login >= DATE_SUB(NOW(), INTERVAL ".Config::get(Config::DAEMON_UPDATE_LOGIN_LIMIT)." DAY)";
}
} else {
$login_thresh_qpart = "";
}
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$update_limit_qpart = "AND ((
ttrss_feeds.update_interval = 0
AND ttrss_user_prefs.value != '-1'
@ -96,7 +98,7 @@ class RSSUtils {
}
// Test if feed is currently being updated by another process.
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '10 minutes')";
} else {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 10 MINUTE))";
@ -106,7 +108,7 @@ class RSSUtils {
// Update the least recently updated feeds first
$query_order = "ORDER BY last_updated";
if (DB_TYPE == "pgsql") $query_order .= " NULLS FIRST";
if (Config::get(Config::DB_TYPE) == "pgsql") $query_order .= " NULLS FIRST";
$query = "SELECT DISTINCT ttrss_feeds.feed_url, ttrss_feeds.last_updated
FROM
@ -182,7 +184,7 @@ class RSSUtils {
if (self::function_enabled('passthru')) {
$exit_code = 0;
passthru(PHP_EXECUTABLE . " update.php --update-feed " . $tline["id"] . " --pidlock feed-" . $tline["id"] . " $quiet $log $log_level", $exit_code);
passthru(Config::get(Config::PHP_EXECUTABLE) . " update.php --update-feed " . $tline["id"] . " --pidlock feed-" . $tline["id"] . " $quiet $log $log_level", $exit_code);
Debug::log(sprintf("<= %.4f (sec) exit code: %d", microtime(true) - $fstarted, $exit_code));
@ -275,7 +277,7 @@ class RSSUtils {
$pluginhost = new PluginHost();
$user_plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
$pluginhost->load(PLUGINS, PluginHost::KIND_ALL);
$pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
$pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid);
//$pluginhost->load_data();
@ -288,7 +290,7 @@ class RSSUtils {
if (!$basic_info) {
$feed_data = UrlHelper::fetch($fetch_url, false,
$auth_login, $auth_pass, false,
FEED_FETCH_TIMEOUT,
Config::get(Config::FEED_FETCH_TIMEOUT),
0);
$feed_data = trim($feed_data);
@ -395,12 +397,12 @@ class RSSUtils {
$date_feed_processed = date('Y-m-d H:i');
$cache_filename = CACHE_DIR . "/feeds/" . sha1($fetch_url) . ".xml";
$cache_filename = Config::get(Config::CACHE_DIR) . "/feeds/" . sha1($fetch_url) . ".xml";
$pluginhost = new PluginHost();
$user_plugins = get_pref("_ENABLED_PLUGINS", $owner_uid);
$pluginhost->load(PLUGINS, PluginHost::KIND_ALL);
$pluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
$pluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid);
//$pluginhost->load_data();
@ -455,7 +457,7 @@ class RSSUtils {
Debug::log("not using CURL due to open_basedir restrictions", Debug::$LOG_VERBOSE);
}
if (time() - strtotime($last_unconditional) > MAX_CONDITIONAL_INTERVAL) {
if (time() - strtotime($last_unconditional) > Config::get(Config::MAX_CONDITIONAL_INTERVAL)) {
Debug::log("maximum allowed interval for conditional requests exceeded, forcing refetch", Debug::$LOG_VERBOSE);
$force_refetch = true;
@ -469,7 +471,7 @@ class RSSUtils {
"url" => $fetch_url,
"login" => $auth_login,
"pass" => $auth_pass,
"timeout" => $no_cache ? FEED_FETCH_NO_CACHE_TIMEOUT : FEED_FETCH_TIMEOUT,
"timeout" => $no_cache ? Config::get(Config::FEED_FETCH_NO_CACHE_TIMEOUT) : Config::get(Config::FEED_FETCH_TIMEOUT),
"last_modified" => $force_refetch ? "" : $stored_last_modified
]);
@ -488,7 +490,7 @@ class RSSUtils {
}
// cache vanilla feed data for re-use
if ($feed_data && !$auth_pass && !$auth_login && is_writable(CACHE_DIR . "/feeds")) {
if ($feed_data && !$auth_pass && !$auth_login && is_writable(Config::get(Config::CACHE_DIR) . "/feeds")) {
$new_rss_hash = sha1($feed_data);
if ($new_rss_hash != $rss_hash) {
@ -561,7 +563,7 @@ class RSSUtils {
Debug::log("language: $feed_language", Debug::$LOG_VERBOSE);
Debug::log("processing feed data...", Debug::$LOG_VERBOSE);
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$favicon_interval_qpart = "favicon_last_checked < NOW() - INTERVAL '12 hour'";
} else {
$favicon_interval_qpart = "favicon_last_checked < DATE_SUB(NOW(), INTERVAL 12 HOUR)";
@ -591,10 +593,10 @@ class RSSUtils {
/* terrible hack: if we crash on floicon shit here, we won't check
* the icon avgcolor again (unless the icon got updated) */
$favicon_file = ICONS_DIR . "/$feed.ico";
$favicon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico";
$favicon_modified = file_exists($favicon_file) ? filemtime($favicon_file) : -1;
Debug::log("checking favicon...", Debug::$LOG_VERBOSE);
Debug::log("checking favicon for feed $feed...", Debug::$LOG_VERBOSE);
self::check_feed_favicon($site_url, $feed);
$favicon_modified_new = file_exists($favicon_file) ? filemtime($favicon_file) : -1;
@ -610,7 +612,7 @@ class RSSUtils {
id = ?");
$sth->execute([$feed]);
$favicon_color = calculate_avg_color($favicon_file);
$favicon_color = \Colors\calculate_avg_color($favicon_file);
$favicon_colorstring = ",favicon_avg_color = " . $pdo->quote($favicon_color);
@ -723,9 +725,9 @@ class RSSUtils {
if ($row = $sth->fetch()) {
$base_entry_id = $row["id"];
$entry_stored_hash = $row["content_hash"];
$article_labels = Article::get_article_labels($base_entry_id, $owner_uid);
$article_labels = Article::_get_labels($base_entry_id, $owner_uid);
$existing_tags = Article::get_article_tags($base_entry_id, $owner_uid);
$existing_tags = Article::_get_tags($base_entry_id, $owner_uid);
$entry_tags = array_unique(array_merge($entry_tags, $existing_tags));
} else {
$base_entry_id = false;
@ -739,7 +741,7 @@ class RSSUtils {
$enclosures = array();
$encs = $item->get_enclosures();
$encs = $item->_get_enclosures();
if (is_array($encs)) {
foreach ($encs as $e) {
@ -755,7 +757,7 @@ class RSSUtils {
$e->type, $e->length, $e->title, $e->width, $e->height);
// Yet another episode of "mysql utf8_general_ci is gimped"
if (DB_TYPE == "mysql" && MYSQL_CHARSET != "UTF8MB4") {
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") {
for ($i = 0; $i < count($e_item); $i++) {
if (is_string($e_item[$i])) {
$e_item[$i] = self::strip_utf8mb4($e_item[$i]);
@ -833,7 +835,7 @@ class RSSUtils {
Debug::log("plugin data: $entry_plugin_data", Debug::$LOG_VERBOSE);
// Workaround: 4-byte unicode requires utf8mb4 in MySQL. See https://tt-rss.org/forum/viewtopic.php?f=1&t=3377&p=20077#p20077
if (DB_TYPE == "mysql" && MYSQL_CHARSET != "UTF8MB4") {
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET) != "UTF8MB4") {
foreach ($article as $k => $v) {
// i guess we'll have to take the risk of 4byte unicode labels & tags here
if (is_string($article[$k])) {
@ -1079,7 +1081,7 @@ class RSSUtils {
Debug::log("resulting RID: $entry_ref_id, IID: $entry_int_id", Debug::$LOG_VERBOSE);
if (DB_TYPE == "pgsql")
if (Config::get(Config::DB_TYPE) == "pgsql")
$tsvector_qpart = "tsvector_combined = to_tsvector(:ts_lang, :ts_content),";
else
$tsvector_qpart = "";
@ -1107,7 +1109,7 @@ class RSSUtils {
":lang" => $entry_language,
":id" => $ref_id];
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$params[":ts_lang"] = $feed_language;
$params[":ts_content"] = mb_substr(strip_tags($entry_title . " " . $entry_content), 0, 900000);
}
@ -1239,7 +1241,7 @@ class RSSUtils {
Debug::log("purging feed...", Debug::$LOG_VERBOSE);
Feeds::purge_feed($feed, 0);
Feeds::_purge($feed, 0);
$sth = $pdo->prepare("UPDATE ttrss_feeds SET
last_updated = NOW(),
@ -1281,7 +1283,7 @@ class RSSUtils {
static function cache_enclosures($enclosures, $site_url) {
$cache = new DiskCache("images");
if ($cache->isWritable()) {
if ($cache->is_writable()) {
foreach ($enclosures as $enc) {
if (preg_match("/(image|audio|video)/", $enc[1])) {
@ -1298,7 +1300,7 @@ class RSSUtils {
$file_content = UrlHelper::fetch(array("url" => $src,
"http_referrer" => $src,
"max_size" => MAX_CACHE_FILE_SIZE));
"max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)));
if ($file_content) {
$cache->put($local_filename, $file_content);
@ -1328,14 +1330,14 @@ class RSSUtils {
$file_content = UrlHelper::fetch(array("url" => $url,
"http_referrer" => $url,
"max_size" => MAX_CACHE_FILE_SIZE));
"max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)));
if ($file_content) {
$cache->put($local_filename, $file_content);
} else {
Debug::log("cache_media: failed with $fetch_last_error_code: $fetch_last_error");
}
} else if ($cache->isWritable($local_filename)) {
} else if ($cache->is_writable($local_filename)) {
$cache->touch($local_filename);
}
}
@ -1344,7 +1346,7 @@ class RSSUtils {
static function cache_media($html, $site_url) {
$cache = new DiskCache("images");
if ($html && $cache->isWritable()) {
if ($html && $cache->is_writable()) {
$doc = new DOMDocument();
if (@$doc->loadHTML($html)) {
$xpath = new DOMXPath($doc);
@ -1375,7 +1377,7 @@ class RSSUtils {
$pdo = Db::pdo();
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$pdo->query("DELETE FROM ttrss_error_log
WHERE created_at < NOW() - INTERVAL '7 days'");
} else {
@ -1396,8 +1398,8 @@ class RSSUtils {
$num_deleted = 0;
if (is_writable(LOCK_DIRECTORY)) {
$files = glob(LOCK_DIRECTORY . "/*.lock");
if (is_writable(Config::get(Config::LOCK_DIRECTORY))) {
$files = glob(Config::get(Config::LOCK_DIRECTORY) . "/*.lock");
if ($files) {
foreach ($files as $file) {
@ -1581,17 +1583,17 @@ class RSSUtils {
}
static function disable_failed_feeds() {
if (defined('DAEMON_UNSUCCESSFUL_DAYS_LIMIT') && DAEMON_UNSUCCESSFUL_DAYS_LIMIT > 0) {
if (Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT) > 0) {
$pdo = Db::pdo();
$pdo->beginTransaction();
$days = DAEMON_UNSUCCESSFUL_DAYS_LIMIT;
$days = Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT);
if (DB_TYPE == "pgsql") {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_query = "last_successful_update < NOW() - INTERVAL '$days days' AND last_updated > NOW() - INTERVAL '1 days'";
} else /* if (DB_TYPE == "mysql") */ {
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_query = "last_successful_update < DATE_SUB(NOW(), INTERVAL $days DAY) AND last_updated > DATE_SUB(NOW(), INTERVAL 1 DAY)";
}
@ -1604,10 +1606,10 @@ class RSSUtils {
while ($row = $sth->fetch()) {
Logger::get()->log(E_USER_NOTICE,
sprintf("Auto disabling feed %d (%s, UID: %d) because it failed to update for %d days.",
$row["id"], clean($row["title"]), $row["owner_uid"], DAEMON_UNSUCCESSFUL_DAYS_LIMIT));
$row["id"], clean($row["title"]), $row["owner_uid"], Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT)));
Debug::log(sprintf("Auto-disabling feed %d (%s) (failed to update for %d days).", $row["id"],
clean($row["title"]), DAEMON_UNSUCCESSFUL_DAYS_LIMIT));
clean($row["title"]), Config::get(Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT)));
}
$sth = $pdo->prepare("UPDATE ttrss_feeds SET update_interval = -1 WHERE
@ -1636,65 +1638,74 @@ class RSSUtils {
self::cleanup_feed_icons();
self::disable_failed_feeds();
Article::purge_orphans();
Article::_purge_orphans();
self::cleanup_counters_cache();
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING);
}
static function check_feed_favicon($site_url, $feed) {
# print "FAVICON [$site_url]: $favicon_url\n";
$icon_file = ICONS_DIR . "/$feed.ico";
$icon_file = Config::get(Config::ICONS_DIR) . "/$feed.ico";
if (!file_exists($icon_file)) {
$favicon_url = self::get_favicon_url($site_url);
$favicon_url = self::get_favicon_url($site_url);
if (!$favicon_url) {
Debug::log("couldn't find favicon URL in $site_url", Debug::$LOG_VERBOSE);
return false;
}
if ($favicon_url) {
// Limiting to "image" type misses those served with text/plain
$contents = UrlHelper::fetch($favicon_url); // , "image");
// Limiting to "image" type misses those served with text/plain
$contents = UrlHelper::fetch([
'url' => $favicon_url,
'max_size' => Config::get(Config::MAX_FAVICON_FILE_SIZE),
//'type' => 'image',
]);
if (!$contents) {
Debug::log("fetching favicon $favicon_url failed", Debug::$LOG_VERBOSE);
return false;
}
if ($contents) {
// Crude image type matching.
// Patterns gleaned from the file(1) source code.
if (preg_match('/^\x00\x00\x01\x00/', $contents)) {
// 0 string \000\000\001\000 MS Windows icon resource
//error_log("check_feed_favicon: favicon_url=$favicon_url isa MS Windows icon resource");
}
elseif (preg_match('/^GIF8/', $contents)) {
// 0 string GIF8 GIF image data
//error_log("check_feed_favicon: favicon_url=$favicon_url isa GIF image");
}
elseif (preg_match('/^\x89PNG\x0d\x0a\x1a\x0a/', $contents)) {
// 0 string \x89PNG\x0d\x0a\x1a\x0a PNG image data
//error_log("check_feed_favicon: favicon_url=$favicon_url isa PNG image");
}
elseif (preg_match('/^\xff\xd8/', $contents)) {
// 0 beshort 0xffd8 JPEG image data
//error_log("check_feed_favicon: favicon_url=$favicon_url isa JPG image");
}
elseif (preg_match('/^BM/', $contents)) {
// 0 string BM PC bitmap (OS2, Windows BMP files)
//error_log("check_feed_favicon, favicon_url=$favicon_url isa BMP image");
}
else {
//error_log("check_feed_favicon: favicon_url=$favicon_url isa UNKNOWN type");
$contents = "";
}
}
// Crude image type matching.
// Patterns gleaned from the file(1) source code.
if (preg_match('/^\x00\x00\x01\x00/', $contents)) {
// 0 string \000\000\001\000 MS Windows icon resource
//error_log("check_feed_favicon: favicon_url=$favicon_url isa MS Windows icon resource");
}
elseif (preg_match('/^GIF8/', $contents)) {
// 0 string GIF8 GIF image data
//error_log("check_feed_favicon: favicon_url=$favicon_url isa GIF image");
}
elseif (preg_match('/^\x89PNG\x0d\x0a\x1a\x0a/', $contents)) {
// 0 string \x89PNG\x0d\x0a\x1a\x0a PNG image data
//error_log("check_feed_favicon: favicon_url=$favicon_url isa PNG image");
}
elseif (preg_match('/^\xff\xd8/', $contents)) {
// 0 beshort 0xffd8 JPEG image data
//error_log("check_feed_favicon: favicon_url=$favicon_url isa JPG image");
}
elseif (preg_match('/^BM/', $contents)) {
// 0 string BM PC bitmap (OS2, Windows BMP files)
//error_log("check_feed_favicon, favicon_url=$favicon_url isa BMP image");
}
else {
//error_log("check_feed_favicon: favicon_url=$favicon_url isa UNKNOWN type");
Debug::log("favicon $favicon_url type is unknown (not updating)", Debug::$LOG_VERBOSE);
return false;
}
if ($contents) {
$fp = @fopen($icon_file, "w");
Debug::log("setting contents of $icon_file", Debug::$LOG_VERBOSE);
if ($fp) {
fwrite($fp, $contents);
fclose($fp);
chmod($icon_file, 0644);
}
}
}
return $icon_file;
$fp = @fopen($icon_file, "w");
if (!$fp) {
Debug::log("failed to open $icon_file for writing", Debug::$LOG_VERBOSE);
return false;
}
fwrite($fp, $contents);
fclose($fp);
chmod($icon_file, 0644);
clearstatcache();
return $icon_file;
}
static function is_gzipped($feed_data) {
@ -1706,7 +1717,7 @@ class RSSUtils {
$filters = array();
$feed_id = (int) $feed_id;
$cat_id = (int)Feeds::getFeedCategory($feed_id);
$cat_id = (int)Feeds::_cat_of_feed($feed_id);
if ($cat_id == 0)
$null_cat_qpart = "cat_id IS NULL OR";
@ -1720,7 +1731,7 @@ class RSSUtils {
$sth->execute([$owner_uid]);
$check_cats = array_merge(
Feeds::getParentCategories($cat_id, $owner_uid),
Feeds::_get_parent_cats($cat_id, $owner_uid),
[$cat_id]);
$check_cats_str = join(",", $check_cats);

@ -123,9 +123,9 @@ class UrlHelper {
'protocol_version'=> 1.1)
);
if (defined('_HTTP_PROXY')) {
if (Config::get(Config::HTTP_PROXY)) {
$context_options['http']['request_fulluri'] = true;
$context_options['http']['proxy'] = _HTTP_PROXY;
$context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY);
}
$context = stream_context_create($context_options);
@ -209,7 +209,7 @@ class UrlHelper {
$last_modified = isset($options["last_modified"]) ? $options["last_modified"] : "";
$useragent = isset($options["useragent"]) ? $options["useragent"] : false;
$followlocation = isset($options["followlocation"]) ? $options["followlocation"] : true;
$max_size = isset($options["max_size"]) ? $options["max_size"] : MAX_DOWNLOAD_FILE_SIZE; // in bytes
$max_size = isset($options["max_size"]) ? $options["max_size"] : Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes
$http_accept = isset($options["http_accept"]) ? $options["http_accept"] : false;
$http_referrer = isset($options["http_referrer"]) ? $options["http_referrer"] : false;
@ -231,7 +231,7 @@ class UrlHelper {
return false;
}
if (!defined('NO_CURL') && function_exists('curl_init') && !ini_get("open_basedir")) {
if (function_exists('curl_init') && !ini_get("open_basedir")) {
$fetch_curl_used = true;
@ -250,8 +250,8 @@ class UrlHelper {
if (count($curl_http_headers) > 0)
curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_http_headers);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : FILE_FETCH_TIMEOUT);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT));
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT));
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, !ini_get("open_basedir") && $followlocation);
curl_setopt($ch, CURLOPT_MAXREDIRS, 20);
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
@ -283,8 +283,8 @@ class UrlHelper {
curl_setopt($ch, CURLOPT_COOKIEJAR, "/dev/null");
}
if (defined('_HTTP_PROXY')) {
curl_setopt($ch, CURLOPT_PROXY, _HTTP_PROXY);
if (Config::get(Config::HTTP_PROXY)) {
curl_setopt($ch, CURLOPT_PROXY, Config::get(Config::HTTP_PROXY));
}
if ($post_query) {
@ -395,7 +395,7 @@ class UrlHelper {
),
'method' => 'GET',
'ignore_errors' => true,
'timeout' => $timeout ? $timeout : FILE_FETCH_TIMEOUT,
'timeout' => $timeout ? $timeout : Config::get(Config::FILE_FETCH_TIMEOUT),
'protocol_version'=> 1.1)
);
@ -408,16 +408,16 @@ class UrlHelper {
if ($http_referrer)
array_push($context_options['http']['header'], "Referer: $http_referrer");
if (defined('_HTTP_PROXY')) {
if (Config::get(Config::HTTP_PROXY)) {
$context_options['http']['request_fulluri'] = true;
$context_options['http']['proxy'] = _HTTP_PROXY;
$context_options['http']['proxy'] = Config::get(Config::HTTP_PROXY);
}
$context = stream_context_create($context_options);
$old_error = error_get_last();
$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : FILE_FETCH_CONNECT_TIMEOUT);
$fetch_effective_url = self::resolve_redirects($url, $timeout ? $timeout : Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT));
if (!self::validate($fetch_effective_url, true)) {
$fetch_last_error = "URL received after redirection failed extended validation.";

@ -2,7 +2,7 @@
class UserHelper {
static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null) {
if (!SINGLE_USER_MODE) {
if (!Config::get(Config::SINGLE_USER_MODE)) {
$user_id = false;
$auth_module = false;
@ -41,7 +41,7 @@ class UserHelper {
$_SESSION["user_agent"] = sha1($_SERVER['HTTP_USER_AGENT']);
$_SESSION["pwd_hash"] = $row["pwd_hash"];
Pref_Prefs::initialize_user_prefs($_SESSION["uid"]);
Pref_Prefs::_init_user_prefs($_SESSION["uid"]);
return true;
}
@ -64,7 +64,7 @@ class UserHelper {
$_SESSION["ip_address"] = UserHelper::get_user_ip();
Pref_Prefs::initialize_user_prefs($_SESSION["uid"]);
Pref_Prefs::_init_user_prefs($_SESSION["uid"]);
return true;
}
@ -88,26 +88,26 @@ class UserHelper {
static function login_sequence() {
$pdo = Db::pdo();
if (SINGLE_USER_MODE) {
if (Config::get(Config::SINGLE_USER_MODE)) {
@session_start();
self::authenticate("admin", null);
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
} else {
if (!validate_session()) $_SESSION["uid"] = false;
if (!\Sessions\validate_session()) $_SESSION["uid"] = false;
if (empty($_SESSION["uid"])) {
if (AUTH_AUTO_LOGIN && self::authenticate(null, null)) {
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
$_SESSION["ref_schema_version"] = get_schema_version(true);
} else {
self::authenticate(null, null, true);
}
if (empty($_SESSION["uid"])) {
Pref_Users::logout_user();
UserHelper::logout();
Handler_Public::render_login_form();
Handler_Public::_render_login_form();
exit;
}
@ -157,4 +157,46 @@ class UserHelper {
return false;
}
static function logout() {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time()-42000, '/');
}
session_commit();
}
static function reset_password($uid, $format_output = false) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT login FROM ttrss_users WHERE id = ?");
$sth->execute([$uid]);
if ($row = $sth->fetch()) {
$login = $row["login"];
$new_salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$tmp_user_pwd = make_password();
$pwd_hash = encrypt_password($tmp_user_pwd, $new_salt, true);
$sth = $pdo->prepare("UPDATE ttrss_users
SET pwd_hash = ?, salt = ?, otp_enabled = false
WHERE id = ?");
$sth->execute([$pwd_hash, $new_salt, $uid]);
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
if ($format_output)
print_notice($message);
else
print $message;
}
}
}

@ -1,153 +1,18 @@
<?php
// *******************************************
// *** Database configuration (important!) ***
// *******************************************
/*
This file can be used to customize global defaults if environment method is not available (i.e. no Docker).
define('DB_TYPE', '%DB_TYPE'); // pgsql or mysql
define('DB_HOST', '%DB_HOST');
define('DB_USER', '%DB_USER');
define('DB_NAME', '%DB_NAME');
define('DB_PASS', '%DB_PASS');
define('DB_PORT', '%DB_PORT'); // usually 5432 for PostgreSQL, 3306 for MySQL
Use the following syntax to override defaults (options are declared in classes/config.php, prefixed by TTRSS_):
define('MYSQL_CHARSET', 'UTF8');
// 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.
putenv('TTRSS_DB_HOST=myserver');
putenv('TTRSS_SELF_URL_PATH=http://example.com/tt-rss');
// ***********************************
// *** Basic settings (important!) ***
// ***********************************
Plugin-required constants also go here, using define():
define('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.org/tt-rss/
// The value should be a constant string literal. Please don't use
// PHP server variables here - you might introduce security
// issues on your install and cause hard to debug problems.
// If your tt-rss instance is behind a reverse proxy, use the external URL.
define('LEGACY_CONSTANT', 'value');
define('SINGLE_USER_MODE', false);
// 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).
etc.
define('SIMPLE_UPDATE_MODE', false);
// 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.
// Still, there are more robust (and recommended) updating methods
// available, you can read about them here: https://tt-rss.org/wiki/UpdatingFeeds
See this page for more information: https://tt-rss.org/wiki/GlobalConfig
*/
// *****************************
// *** Files and directories ***
// *****************************
define('PHP_EXECUTABLE', '/usr/bin/php');
// Path to PHP *COMMAND LINE* executable, used for various command-line tt-rss
// programs and update daemon. Do not try to use CGI binary here, it won't work.
// If you see HTTP headers being displayed while running tt-rss scripts,
// then most probably you are using the CGI binary. If you are unsure what to
// put in here, ask your hosting provider.
define('LOCK_DIRECTORY', 'lock');
// Directory for lockfiles, must be writable to the user you run
// daemon process or cronjobs under.
define('CACHE_DIR', 'cache');
// Local cache directory for RSS feed content.
define('ICONS_DIR', "feed-icons");
define('ICONS_URL', "feed-icons");
// Local and URL path to the directory, where feed favicons are stored.
// Unless you really know what you're doing, please keep those relative
// to tt-rss main directory.
// **********************
// *** Authentication ***
// **********************
// Please see PLUGINS below to configure various authentication modules.
define('AUTH_AUTO_CREATE', true);
// Allow authentication modules to auto-create users in tt-rss internal
// database when authenticated successfully.
define('AUTH_AUTO_LOGIN', true);
// Automatically login user on remote or other kind of externally supplied
// authentication, otherwise redirect to login form as normal.
// If set to true, users won't be able to set application language
// and settings profile.
// *********************
// *** Feed settings ***
// *********************
define('FORCE_ARTICLE_PURGE', 0);
// When this option is not 0, users ability to control feed purging
// intervals is disabled and all articles (which are not starred)
// older than this amount of days are purged.
// **********************************
// *** Cookies and login sessions ***
// **********************************
define('SESSION_COOKIE_LIFETIME', 86400);
// Default lifetime of a session (e.g. login) cookie. In seconds,
// 0 means cookie will be deleted when browser closes.
// *********************************
// *** Email and digest settings ***
// *********************************
// Tiny Tiny RSS sends mail via PHP mail() function, unless handled
// by a plugin.
// If you need SMTP support, take a look here:
// https://git.tt-rss.org/fox/ttrss-mailer-smtp
define('SMTP_FROM_NAME', 'Tiny Tiny RSS');
define('SMTP_FROM_ADDRESS', 'noreply@your.domain.dom');
// Name, address and subject for sending outgoing mail. This applies
// to password reset notifications, digest emails and any other mail.
define('DIGEST_SUBJECT', '[tt-rss] New headlines for last 24 hours');
// Subject line for email digests
// ***************************************
// *** Other settings (less important) ***
// ***************************************
define('CHECK_FOR_UPDATES', true);
// Check for updates automatically if running Git version
define('ENABLE_GZIP_OUTPUT', false);
// Selectively gzip output to improve wire performance. This requires
// PHP Zlib extension on the server.
// Enabling this can break tt-rss in several httpd/php configurations,
// if you experience weird errors and tt-rss failing to start, blank pages
// after login, or content encoding errors, disable it.
define('PLUGINS', 'auth_internal, note');
// Comma-separated list of plugins to load automatically for all users.
// System plugins have to be specified here. Please enable at least one
// authentication plugin here (auth_*).
// Users may enable other user plugins from Preferences/Plugins but may not
// disable plugins specified in this list.
// Disabling auth_internal in this list would automatically disable
// reset password link on the login form.
define('LOG_DESTINATION', 'sql');
// Error log destination to use. Possible values: sql (uses internal logging
// you can read in Preferences -> System), syslog - logs to system log.
// Setting this to blank uses PHP logging (usually to http server
// error.log).
// Note that feed updating daemons don't use this logging facility
// for normal output.
define('CONFIG_VERSION', 26);
// Expected config version. Please update this option in config.php
// if necessary (after migrating all new options from this file).
// vim:ft=php

@ -1,60 +0,0 @@
<?php
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
get_include_path());
require_once "functions.php";
function get_error_types() {
$ERRORS[0] = "";
$ERRORS[1] = __("This program requires XmlHttpRequest " .
"to function properly. Your browser doesn't seem to support it.");
$ERRORS[2] = __("This program requires cookies " .
"to function properly. Your browser doesn't seem to support them.");
$ERRORS[3] = __("Backend sanity check failed.");
$ERRORS[4] = __("Frontend sanity check failed.");
$ERRORS[5] = __("Incorrect database schema version. &lt;a href='db-updater.php'&gt;Please update&lt;/a&gt;.");
$ERRORS[6] = __("Request not authorized.");
$ERRORS[7] = __("No operation to perform.");
$ERRORS[8] = __("Could not display feed: query failed. Please check label match syntax or local configuration.");
$ERRORS[8] = __("Denied. Your access level is insufficient to access this page.");
$ERRORS[9] = __("Configuration check failed");
$ERRORS[10] = __("Your version of MySQL is not currently supported. Please see official site for more information.");
$ERRORS[11] = "[This error is not returned by server]";
$ERRORS[12] = __("SQL escaping test failed, check your database and PHP configuration");
$ERRORS[13] = __("Method not found");
$ERRORS[14] = __("Plugin not found");
$ERRORS[15] = __("Encoding data as JSON failed");
return $ERRORS;
}
if ($_REQUEST['mode'] ?? "" == 'js') {
header("Content-Type: text/javascript; charset=UTF-8");
print "var ERRORS = [];\n";
foreach (get_error_types() as $id => $error) {
$error = preg_replace("/\n/", "", $error);
$error = preg_replace("/\"/", "\\\"", $error);
print "ERRORS[$id] = \"$error\";\n";
}
}
?>

@ -1,6 +1,4 @@
<?php
require_once "functions.php";
spl_autoload_register(function($class) {
$namespace = '';
$class_name = $class;

@ -1,4 +1,5 @@
<?php
namespace Colors;
if (file_exists("lib/floIcon.php")) {
require_once "lib/floIcon.php";
@ -297,7 +298,7 @@ function hsl2rgb($arr) {
if (class_exists("floIcon")) {
$ico = new floIcon();
$ico = new \floIcon();
@$ico->readICO($imageFile);
if(count($ico->images)==0)

@ -1,347 +1,183 @@
<?php
namespace Controls;
function print_select($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
function attributes_to_string(array $attributes) {
$rv = "";
print "<select name=\"$name\" id=\"$id\" $attributes>";
foreach ($values as $v) {
if ($v == $default)
$sel = "selected=\"1\"";
else
$sel = "";
foreach ($attributes as $k => $v) {
$v = trim($v);
// special handling for "disabled"
if ($k === "disabled" && !sql_bool_to_bool($v))
continue;
print "<option value=\"$v\" $sel>$v</option>";
}
print "</select>";
}
$rv .= "$k=\"" . htmlspecialchars($v) . "\"";
}
function print_select_hash($id, $default, $values, $attributes = "", $name = "") {
if (!$name) $name = $id;
return $rv;
}
print "<select name=\"$name\" id='$id' $attributes>";
foreach (array_keys($values) as $v) {
if ($v == $default)
$sel = 'selected="selected"';
else
$sel = "";
// shortcut syntax (disabled)
/* function pluginhandler_tags(\Plugin $plugin, string $method) {
return hidden_tag("op", strtolower(get_class($plugin) . \PluginHost::PUBLIC_METHOD_DELIMITER . $method));
} */
$v = trim($v);
function public_method_tags(\Plugin $plugin, string $method) {
return hidden_tag("op", strtolower(get_class($plugin) . \PluginHost::PUBLIC_METHOD_DELIMITER . $method));
}
print "<option $sel value=\"$v\">".$values[$v]."</option>";
}
function pluginhandler_tags(\Plugin $plugin, string $method) {
return hidden_tag("op", "pluginhandler") .
hidden_tag("plugin", strtolower(get_class($plugin))) .
hidden_tag("method", $method);
}
print "</select>";
}
function button_tag(string $value, string $type, array $attributes = []) {
return "<button dojoType=\"dijit.form.Button\" ".attributes_to_string($attributes)." type=\"$type\">$value</button>";
}
function format_hidden($name, $value) {
return "<input dojoType=\"dijit.form.TextBox\" style=\"display : none\" name=\"$name\" value=\"$value\">";
}
function input_tag(string $name, string $value, string $type = "text", array $attributes = [], string $id = "") {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='dijit.form.TextBox'" : "";
function print_hidden($name, $value) {
print format_hidden($name, $value);
}
return "<input name=\"".htmlspecialchars($name)."\" $dojo_type ".attributes_to_string($attributes)." id=\"".htmlspecialchars($id)."\"
type=\"$type\" value=\"".htmlspecialchars($value)."\">";
}
function format_checkbox($id, $checked, $value = "", $attributes = "") {
$checked_str = $checked ? "checked" : "";
$value_str = $value ? "value=\"$value\"" : "";
function number_spinner_tag(string $name, string $value, array $attributes = [], string $id = "") {
return input_tag($name, $value, "text", array_merge(["dojoType" => "dijit.form.NumberSpinner"], $attributes), $id);
}
return "<input dojoType=\"dijit.form.CheckBox\" id=\"$id\" $value_str $checked_str $attributes name=\"$id\">";
}
function submit_tag(string $value, array $attributes = []) {
return button_tag($value, "submit", array_merge(["class" => "alt-primary"], $attributes));
}
function print_checkbox($id, $checked, $value = "", $attributes = "") {
print format_checkbox($id, $checked, $value, $attributes);
}
function cancel_dialog_tag(string $value, array $attributes = []) {
return button_tag($value, "", array_merge(["onclick" => "App.dialogOf(this).hide()"], $attributes));
}
function print_button($type, $value, $attributes = "") {
print "<p><button dojoType=\"dijit.form.Button\" $attributes type=\"$type\">$value</button>";
}
function icon(string $icon, array $attributes = []) {
return "<i class=\"material-icons\" ".attributes_to_string($attributes).">$icon</i>";
}
function print_radio($id, $default, $true_is, $values, $attributes = "") {
foreach ($values as $v) {
function select_tag(string $name, $value, array $values, array $attributes = [], string $id = "") {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
if ($v == $default)
$sel = "checked";
else
$sel = "";
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";
if ($v == $true_is) {
$sel .= " value=\"1\"";
} else {
$sel .= " value=\"0\"";
}
foreach ($values as $v) {
$is_sel = ($v == $value) ? "selected=\"selected\"" : "";
print "<input class=\"noborder\" dojoType=\"dijit.form.RadioButton\"
type=\"radio\" $sel $attributes name=\"$id\">&nbsp;$v&nbsp;";
$rv .= "<option value=\"".htmlspecialchars($v)."\" $is_sel>".htmlspecialchars($v)."</option>";
}
}
}
$rv .= "</select>";
function print_feed_multi_select($id, $default_ids = [],
$attributes = "", $include_all_feeds = true,
$root_id = null, $nest_level = 0) {
return $rv;
}
$pdo = Db::pdo();
/*function select_labels(string $name, string $value, array $attributes = [], string $id = "") {
$values = \Labels::get_as_hash($_SESSION["uid"]);
print_r(in_array("CAT:6",$default_ids));
return select_tag($name, $value, $values, $attributes, $id);
}*/
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>";
}
}
function select_hash(string $name, $value, array $values, array $attributes = [], string $id = "") {
$attributes_str = attributes_to_string($attributes);
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
if (get_pref('ENABLE_FEED_CATS')) {
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";
if (!$root_id) $root_id = null;
foreach ($values as $k => $v) {
$is_sel = ($k == $value) ? "selected=\"selected\"" : "";
$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");
$rv .= "<option value=\"".htmlspecialchars($k)."\" $is_sel>".htmlspecialchars($v)."</option>";
}
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
$rv .= "</select>";
while ($line = $sth->fetch()) {
return $rv;
}
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
function hidden_tag(string $name, string $value, array $attributes = []) {
return "<input dojoType=\"dijit.form.TextBox\" style=\"display : none\"
".attributes_to_string($attributes)." name=\"".htmlspecialchars($name)."\"
value=\"".htmlspecialchars($value)."\">";
}
$is_selected = in_array("CAT:".$line["id"], $default_ids) ? "selected=\"1\"" : "";
function checkbox_tag(string $name, bool $checked = false, string $value = "", array $attributes = [], string $id = "") {
$is_checked = $checked ? "checked" : "";
$value_str = $value ? "value=\"".htmlspecialchars($value)."\"" : "";
printf("<option $is_selected value='CAT:%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
return "<input dojoType='dijit.form.CheckBox' name=\"".htmlspecialchars($name)."\"
$value_str $is_checked ".attributes_to_string($attributes)." id=\"".htmlspecialchars($id)."\">";
}
if ($line["num_children"] > 0)
print_feed_multi_select($id, $default_ids, $attributes,
$include_all_feeds, $line["id"], $nest_level+1);
function select_feeds_cats(string $name, int $default_id = null, array $attributes = [],
bool $include_all_cats = true, string $root_id = null, int $nest_level = 0, string $id = "") {
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE cat_id = ? AND owner_uid = ? ORDER BY title");
$ret = "";
$f_sth->execute([$line['id'], $_SESSION['uid']]);
if (!$root_id) {
$ret .= "<select name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\"
default=\"".((string)$default_id)."\"
dojoType=\"fox.form.Select\" ".attributes_to_string($attributes).">";
}
while ($fline = $f_sth->fetch()) {
$is_selected = (in_array($fline["id"], $default_ids)) ? "selected=\"1\"" : "";
$pdo = \Db::pdo();
$fline["title"] = "" . $fline["title"];
if (!$root_id) $root_id = null;
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = "" . $fline["title"];
$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]);
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
$found = 0;
if (!$root_id) {
$is_selected = in_array("CAT:0", $default_ids) ? "selected=\"1\"" : "";
while ($line = $sth->fetch()) {
++$found;
printf("<option $is_selected value='CAT:0'>%s</option>",
__("Uncategorized"));
if ($line["id"] == $default_id) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
$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']]);
for ($i = 0; $i < $nest_level; $i++)
$line["title"] = "" . $line["title"];
while ($fline = $f_sth->fetch()) {
$is_selected = in_array($fline["id"], $default_ids) ? "selected=\"1\"" : "";
if ($line["title"])
$ret .= sprintf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
$fline["title"] = "" . $fline["title"];
if ($line["num_children"] > 0)
$ret .= select_feeds_cats($id, $default_id, $attributes,
$include_all_cats, $line["id"], $nest_level+1, $id);
}
for ($i = 0; $i < $nest_level; $i++)
$fline["title"] = "" . $fline["title"];
if (!$root_id) {
if ($include_all_cats) {
if ($found > 0) {
$ret .= "<option disabled=\"1\">―――――――――――――――</option>";
}
printf("<option $is_selected value='%d'>%s</option>",
$fline["id"], htmlspecialchars($fline["title"]));
}
}
if ($default_id == 0) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
} else {
$sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$_SESSION['uid']]);
$ret .= "<option $is_selected value=\"0\">".__('Uncategorized')."</option>";
}
$ret .= "</select>";
}
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) {
if (!$root_id) {
print "<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"])
printf("<option $is_selected value='%d'>%s</option>",
$line["id"], htmlspecialchars($line["title"]));
if ($line["num_children"] > 0)
print_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) {
print "<option disabled=\"1\">―――――――――――――――</option>";
}
if ($default_id == 0) {
$is_selected = "selected=\"1\"";
} else {
$is_selected = "";
}
print "<option $is_selected value=\"0\">".__('Uncategorized')."</option>";
}
print "</select>";
}
}
function stylesheet_tag($filename, $id = false) {
$timestamp = filemtime($filename);
$id_part = $id ? "id=\"$id\"" : "";
return "<link rel=\"stylesheet\" $id_part type=\"text/css\" data-orig-href=\"$filename\" href=\"$filename?$timestamp\"/>\n";
}
function javascript_tag($filename) {
$query = "";
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";
}
function format_warning($msg, $id = "") {
return "<div class=\"alert\" id=\"$id\">$msg</div>";
}
function format_notice($msg, $id = "") {
return "<div class=\"alert alert-info\" id=\"$id\">$msg</div>";
}
function format_error($msg, $id = "") {
return "<div class=\"alert alert-danger\" id=\"$id\">$msg</div>";
}
function print_notice($msg) {
return print format_notice($msg);
}
function print_warning($msg) {
return print format_warning($msg);
}
function print_error($msg) {
return print format_error($msg);
}
function format_inline_player($url, $ctype) {
$entry = "";
$url = htmlspecialchars($url);
if (strpos($ctype, "audio/") === 0) {
$entry .= "<div class='inline-player'>";
if ($_SESSION["hasAudio"] && (strpos($ctype, "ogg") !== false ||
$_SESSION["hasMp3"])) {
$entry .= "<audio preload=\"none\" controls>
<source type=\"$ctype\" src=\"$url\"/>
</audio> ";
}
if ($entry) $entry .= "<a target=\"_blank\" rel=\"noopener noreferrer\"
href=\"$url\">" . basename($url) . "</a>";
$entry .= "</div>";
return $entry;
}
return "";
}
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>";
}
return $ret;
}

@ -0,0 +1,314 @@
<?php
function stylesheet_tag($filename, $id = false) {
$timestamp = filemtime($filename);
$id_part = $id ? "id=\"$id\"" : "";
return "<link rel=\"stylesheet\" $id_part type=\"text/css\" data-orig-href=\"$filename\" href=\"$filename?$timestamp\"/>\n";
}
function javascript_tag($filename) {
$query = "";
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";
}
function format_warning($msg, $id = "") {
return "<div class=\"alert\" id=\"$id\">$msg</div>";
}
function format_notice($msg, $id = "") {
return "<div class=\"alert alert-info\" id=\"$id\">$msg</div>";
}
function format_error($msg, $id = "") {
return "<div class=\"alert alert-danger\" id=\"$id\">$msg</div>";
}
function print_notice($msg) {
return print format_notice($msg);
}
function print_warning($msg) {
return print format_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('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>";
}
*/

@ -1,10 +0,0 @@
<?php
require_once "db.php";
function get_pref($pref_name, $user_id = false, $die_on_error = false) {
return Db_Prefs::get()->read($pref_name, $user_id, $die_on_error);
}
function set_pref($pref_name, $value, $user_id = false, $strip_tags = true) {
return Db_Prefs::get()->write($pref_name, $value, $user_id, $strip_tags);
}

@ -1,38 +0,0 @@
<?php
function db_escape_string($s, $strip_tags = true) {
return Db::get()->escape_string($s, $strip_tags);
}
function db_query($query, $die_on_error = true) {
return Db::get()->query($query, $die_on_error);
}
function db_fetch_assoc($result) {
return Db::get()->fetch_assoc($result);
}
function db_num_rows($result) {
return Db::get()->num_rows($result);
}
function db_fetch_result($result, $row, $param) {
return Db::get()->fetch_result($result, $row, $param);
}
function db_affected_rows($result) {
return Db::get()->affected_rows($result);
}
function db_last_error() {
return Db::get()->last_error();
}
function db_last_query_error() {
return Db::get()->last_query_error();
}
function db_quote($str){
return Db::get()->quote($str);
}

@ -48,7 +48,7 @@ function ttrss_error_handler($errno, $errstr, $file, $line) {
if (error_reporting() == 0 || !$errno) return false;
$file = substr(str_replace(dirname(dirname(__FILE__)), "", $file), 1);
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
$context = format_backtrace(debug_backtrace());
$errstr = truncate_middle($errstr, 16384, " (...) ");
@ -72,7 +72,7 @@ function ttrss_fatal_handler() {
$context = format_backtrace(debug_backtrace());
$file = substr(str_replace(dirname(dirname(__FILE__)), "", $file), 1);
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
if ($last_query) $errstr .= " [Last query: $last_query]";

@ -1,16 +1,9 @@
<?php
define('EXPECTED_CONFIG_VERSION', 26);
define('SCHEMA_VERSION', 140);
define('LABEL_BASE_INDEX', -1024);
define('PLUGIN_FEED_BASE_INDEX', -128);
define('COOKIE_LIFETIME_LONG', 86400*365);
// this CSS file is included for everyone (if it exists in themes.local)
// on login, registration, and main (index and prefs) pages
define('LOCAL_OVERRIDE_STYLESHEET', '.local-overrides.css');
$fetch_last_error = false;
$fetch_last_error_code = false;
$fetch_last_content_type = false;
@ -34,71 +27,31 @@
error_reporting(E_ALL & ~E_NOTICE);
}
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
require_once 'config.php';
/**
* Define a constant if not already defined
*/
function define_default($name, $value) {
defined($name) or define($name, $value);
}
/* Some tunables you can override in config.php using define(): */
define_default('FEED_FETCH_TIMEOUT', 45);
// How may seconds to wait for response when requesting feed from a site
define_default('FEED_FETCH_NO_CACHE_TIMEOUT', 15);
// How may seconds to wait for response when requesting feed from a
// site when that feed wasn't cached before
define_default('FILE_FETCH_TIMEOUT', 45);
// Default timeout when fetching files from remote sites
define_default('FILE_FETCH_CONNECT_TIMEOUT', 15);
// How many seconds to wait for initial response from website when
// fetching files from remote sites
define_default('DAEMON_UPDATE_LOGIN_LIMIT', 30);
// stop updating feeds if users haven't logged in for X days
define_default('DAEMON_FEED_LIMIT', 500);
// feed limit for one update batch
define_default('DAEMON_SLEEP_INTERVAL', 120);
// default sleep interval between feed updates (sec)
define_default('MAX_CACHE_FILE_SIZE', 64*1024*1024);
// do not cache files larger than that (bytes)
define_default('MAX_DOWNLOAD_FILE_SIZE', 16*1024*1024);
// do not download general files larger than that (bytes)
define_default('CACHE_MAX_DAYS', 7);
// max age in days for various automatically cached (temporary) files
define_default('MAX_CONDITIONAL_INTERVAL', 3600*12);
// max interval between forced unconditional updates for servers
// not complying with http if-modified-since (seconds)
// define_default('MAX_FETCH_REQUESTS_PER_HOST', 25);
// a maximum amount of allowed HTTP requests per destination host
// during a single update (i.e. within PHP process lifetime)
// this is used to not cause excessive load on the origin server on
// e.g. feed subscription when all articles are being processes
// (not implemented)
define_default('DAEMON_UNSUCCESSFUL_DAYS_LIMIT', 30);
// automatically disable updates for feeds which failed to
// update for this amount of days; 0 disables
/* tunables end here */
if (DB_TYPE == "pgsql") {
ini_set('display_errors', "false");
ini_set('display_startup_errors', "false");
// config.php is optional
if (stream_resolve_include_path("config.php"))
require_once "config.php";
require_once "autoload.php";
if (Config::get(Config::DB_TYPE) == "pgsql") {
define('SUBSTRING_FOR_DATE', 'SUBSTRING_FOR_DATE');
} else {
define('SUBSTRING_FOR_DATE', 'SUBSTRING');
}
/**
* Return available translations names.
*
* @access public
* @return array A array of available translations.
*/
function get_pref($pref_name, $user_id = false, $die_on_error = false) {
return Db_Prefs::get()->read($pref_name, $user_id, $die_on_error);
}
function set_pref($pref_name, $value, $user_id = false, $strip_tags = true) {
return Db_Prefs::get()->write($pref_name, $value, $user_id, $strip_tags);
}
function get_translations() {
$tr = array(
$t = array(
"auto" => __("Detect automatically"),
"ar_SA" => "العربيّة (Arabic)",
"bg_BG" => "Bulgarian",
@ -129,38 +82,76 @@
"fi_FI" => "Suomi",
"tr_TR" => "Türkçe");
return $tr;
return $t;
}
require_once "lib/accept-to-gettext.php";
require_once "lib/gettext/gettext.inc.php";
function startup_gettext() {
# Get locale from Accept-Language header
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
$lang = al2gt(array_keys(get_translations()), "text/html");
} else {
$lang = ""; // FIXME: do something with accept-to-gettext.php
}
$selected_locale = "";
// https://www.codingwithjesse.com/blog/use-accept-language-header/
if (!empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$valid_langs = [];
$translations = array_keys(get_translations());
array_shift($translations); // remove "auto"
// full locale first
foreach ($translations as $t) {
$lang = strtolower(str_replace("_", "-", (string)$t));
$valid_langs[$lang] = $t;
$lang = substr($lang, 0, 2);
if (!isset($valid_langs[$lang]))
$valid_langs[$lang] = $t;
}
// break up string into pieces (languages and q factors)
preg_match_all('/([a-z]{1,8}(-[a-z]{1,8})?)\s*(;\s*q\s*=\s*(1|0\.[0-9]+))?/i',
$_SERVER['HTTP_ACCEPT_LANGUAGE'], $lang_parse);
if (count($lang_parse[1])) {
// create a list like "en" => 0.8
$langs = array_combine($lang_parse[1], $lang_parse[4]);
if (defined('_TRANSLATION_OVERRIDE_DEFAULT')) {
$lang = _TRANSLATION_OVERRIDE_DEFAULT;
if (is_array($langs)) {
// set default to 1 for any without q factor
foreach ($langs as $lang => $val) {
if ($val === '') $langs[$lang] = 1;
}
// sort list based on value
arsort($langs, SORT_NUMERIC);
foreach (array_keys($langs) as $lang) {
$lang = strtolower($lang);
foreach ($valid_langs as $vlang => $vlocale) {
if ($vlang == $lang) {
$selected_locale = $vlocale;
break 2;
}
}
}
}
}
}
if (!empty($_SESSION["uid"]) && get_schema_version() >= 120) {
$pref_lang = get_pref("USER_LANGUAGE", $_SESSION["uid"]);
$pref_locale = get_pref("USER_LANGUAGE", $_SESSION["uid"]);
if ($pref_lang && $pref_lang != 'auto') {
$lang = $pref_lang;
if (!empty($pref_locale) && $pref_locale != 'auto') {
$selected_locale = $pref_locale;
}
}
if ($lang) {
if ($selected_locale) {
if (defined('LC_MESSAGES')) {
_setlocale(LC_MESSAGES, $lang);
_setlocale(LC_MESSAGES, $selected_locale);
} else if (defined('LC_ALL')) {
_setlocale(LC_ALL, $lang);
_setlocale(LC_ALL, $selected_locale);
}
_bindtextdomain("messages", "locale");
@ -169,8 +160,8 @@
}
}
require_once 'db-prefs.php';
require_once 'controls.php';
require_once 'controls_compat.php';
define('SELF_USER_AGENT', 'Tiny Tiny RSS/' . get_version() . ' (http://tt-rss.org/)');
ini_set('user_agent', SELF_USER_AGENT);
@ -185,7 +176,7 @@
// @deprecated
function getFeedUnread($feed, $is_cat = false) {
return Feeds::getFeedArticles($feed, $is_cat, true, $_SESSION["uid"]);
return Feeds::_get_counters($feed, $is_cat, true, $_SESSION["uid"]);
}
// @deprecated
@ -248,7 +239,7 @@
} else if (is_string($param)) {
return trim(strip_tags($param));
} else {
return trim($param);
return $param;
}
}
@ -278,7 +269,7 @@
}
function validate_csrf($csrf_token) {
return isset($csrf_token) && hash_equals($_SESSION['csrf_token'], $csrf_token);
return isset($csrf_token) && hash_equals($_SESSION['csrf_token'] ?? "", $csrf_token);
}
function truncate_string($str, $max_len, $suffix = '&hellip;') {
@ -332,24 +323,10 @@
}
}
function sanity_check() {
require_once 'errors.php';
$ERRORS = get_error_types();
$error_code = 0;
$schema_version = get_schema_version(true);
if ($schema_version != SCHEMA_VERSION) {
$error_code = 5;
}
return array("code" => $error_code, "message" => $ERRORS[$error_code]);
}
function file_is_locked($filename) {
if (file_exists(LOCK_DIRECTORY . "/$filename")) {
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/$filename")) {
if (function_exists('flock')) {
$fp = @fopen(LOCK_DIRECTORY . "/$filename", "r");
$fp = @fopen(Config::get(Config::LOCK_DIRECTORY) . "/$filename", "r");
if ($fp) {
if (flock($fp, LOCK_EX | LOCK_NB)) {
flock($fp, LOCK_UN);
@ -369,11 +346,11 @@
}
function make_lockfile($filename) {
$fp = fopen(LOCK_DIRECTORY . "/$filename", "w");
$fp = fopen(Config::get(Config::LOCK_DIRECTORY) . "/$filename", "w");
if ($fp && flock($fp, LOCK_EX | LOCK_NB)) {
$stat_h = fstat($fp);
$stat_f = stat(LOCK_DIRECTORY . "/$filename");
$stat_f = stat(Config::get(Config::LOCK_DIRECTORY) . "/$filename");
if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
if ($stat_h["ino"] != $stat_f["ino"] ||
@ -397,7 +374,7 @@
}
function uniqid_short() {
return uniqid(base_convert(rand(), 10, 36));
return uniqid(base_convert((string)rand(), 10, 36));
}
function T_sprintf() {
@ -416,15 +393,15 @@
}
function is_prefix_https() {
return parse_url(SELF_URL_PATH, PHP_URL_SCHEME) == 'https';
return parse_url(Config::get(Config::SELF_URL_PATH), PHP_URL_SCHEME) == 'https';
}
// this returns SELF_URL_PATH sans ending slash
// this returns Config::get(Config::SELF_URL_PATH) sans ending slash
function get_self_url_prefix() {
if (strrpos(SELF_URL_PATH, "/") === strlen(SELF_URL_PATH)-1) {
return substr(SELF_URL_PATH, 0, strlen(SELF_URL_PATH)-1);
if (strrpos(Config::get(Config::SELF_URL_PATH), "/") === strlen(Config::get(Config::SELF_URL_PATH))-1) {
return substr(Config::get(Config::SELF_URL_PATH), 0, strlen(Config::get(Config::SELF_URL_PATH))-1);
} else {
return SELF_URL_PATH;
return Config::get(Config::SELF_URL_PATH);
}
}
@ -439,7 +416,7 @@
} // function encrypt_password
function init_plugins() {
PluginHost::getInstance()->load(PLUGINS, PluginHost::KIND_ALL);
PluginHost::getInstance()->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
return true;
}
@ -542,20 +519,6 @@
return file_exists("themes/$theme") || file_exists("themes.local/$theme");
}
/**
* @SuppressWarnings(unused)
*/
function error_json($code) {
require_once "errors.php";
$ERRORS = get_error_types();
@$message = $ERRORS[$code];
return json_encode(array("error" =>
array("code" => $code, "message" => $message)));
}
function arr_qmarks($arr) {
return str_repeat('?,', count($arr) - 1) . '?';
}
@ -592,7 +555,7 @@
$ttrss_version['version'] = "UNKNOWN (Unsupported)";
date_default_timezone_set('UTC');
$root_dir = dirname(dirname(__FILE__));
$root_dir = dirname(__DIR__);
if (PHP_OS === "Darwin") {
$ttrss_version['version'] = "UNKNOWN (Unsupported, Darwin)";

@ -6,20 +6,17 @@
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<?php
foreach (array("lib/prototype.js",
"lib/dojo/dojo.js",
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"lib/prototype.js",
"js/common.js",
"js/utility.js",
"errors.php?mode=js") as $jsfile) {
"js/utility.js"] as $jsfile) {
echo javascript_tag($jsfile);
} ?>
<?php if (theme_exists(LOCAL_OVERRIDE_STYLESHEET)) {
echo stylesheet_tag(get_theme_path(LOCAL_OVERRIDE_STYLESHEET));
<?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) {
echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET)));
} ?>
<style type="text/css">
@ -61,13 +58,13 @@
if (login && login != this.previousLogin) {
this.previousLogin = login;
xhrJson("public.php", {op: "getprofiles", login: login},
xhr.json("public.php", {op: "getprofiles", login: login},
(reply) => {
const profile = dijit.byId('profile');
profile.removeOption(profile.getOptions());
reply.each((p) => {
reply.forEach((p) => {
profile
.attr("disabled", false)
.addOption(p);
@ -81,7 +78,7 @@
},
bwLimitChange: function(elem) {
Cookie.set("ttrss_bwlimit", elem.checked,
<?php print SESSION_COOKIE_LIFETIME ?>);
<?php print Config::get(Config::SESSION_COOKIE_LIFETIME) ?>);
}
};
@ -92,29 +89,29 @@
<div class="container">
<h1><?php echo "Authentication" ?></h1>
<h1><?= "Authentication" ?></h1>
<div class="content">
<form action="public.php?return=<?php echo $return ?>"
<form action="public.php?return=<?= $return ?>"
dojoType="dijit.form.Form" method="POST">
<?php print_hidden("op", "login"); ?>
<?= \Controls\hidden_tag("op", "login"); ?>
<?php if (!empty($_SESSION["login_error_msg"])) { ?>
<?php echo format_error($_SESSION["login_error_msg"]) ?>
<?= format_error($_SESSION["login_error_msg"]) ?>
<?php $_SESSION["login_error_msg"] = ""; ?>
<?php } ?>
<fieldset>
<label><?php echo __("Login:") ?></label>
<label><?= __("Login:") ?></label>
<input name="login" id="login" dojoType="dijit.form.TextBox" type="text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
required="1" value="<?php echo $_SESSION["fake_login"] ?? "" ?>" />
required="1" value="<?= $_SESSION["fake_login"] ?? "" ?>" />
</fieldset>
<fieldset>
<label><?php echo __("Password:") ?></label>
<label><?= __("Password:") ?></label>
<input type="password" name="password" required="1"
dojoType="dijit.form.TextBox"
@ -122,52 +119,54 @@
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
value="<?php echo $_SESSION["fake_password"] ?? "" ?>"/>
value="<?= $_SESSION["fake_password"] ?? "" ?>"/>
</fieldset>
<?php if (strpos(PLUGINS, "auth_internal") !== false) { ?>
<?php if (strpos(Config::get(Config::PLUGINS), "auth_internal") !== false) { ?>
<fieldset class="align-right">
<a href="public.php?op=forgotpass"><?php echo __("I forgot my password") ?></a>
<a href="public.php?op=forgotpass"><?= __("I forgot my password") ?></a>
</fieldset>
<?php } ?>
<fieldset>
<label><?php echo __("Profile:") ?></label>
<label><?= __("Profile:") ?></label>
<select disabled='disabled' name="profile" id="profile" dojoType='dijit.form.Select'>
<option><?php echo __("Default profile") ?></option>
<option><?= __("Default profile") ?></option>
</select>
</fieldset>
<fieldset class="narrow">
<label> </label>
<label id="bw_limit_label"><input dojoType="dijit.form.CheckBox" name="bw_limit" id="bw_limit"
type="checkbox" onchange="UtilityApp.bwLimitChange(this)">
<?php echo __("Use less traffic") ?></label>
<label id="bw_limit_label">
<?= \Controls\checkbox_tag("bw_limit", false, "",
["onchange" => 'UtilityApp.bwLimitChange(this)'], 'bw_limit') ?>
<?= __("Use less traffic") ?></label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="bw_limit_label" position="below" style="display:none">
<?php echo __("Does not display images in articles, reduces automatic refreshes."); ?>
<?= __("Does not display images in articles, reduces automatic refreshes."); ?>
</div>
<fieldset class="narrow">
<label> </label>
<label id="safe_mode_label"><input dojoType="dijit.form.CheckBox" name="safe_mode" id="safe_mode"
type="checkbox">
<?php echo __("Safe mode") ?></label>
<label id="safe_mode_label">
<?= \Controls\checkbox_tag("safe_mode") ?>
<?= __("Safe mode") ?>
</label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="safe_mode_label" position="below" style="display:none">
<?php echo __("Uses default theme and prevents all plugins from loading."); ?>
<?= __("Uses default theme and prevents all plugins from loading."); ?>
</div>
<?php if (SESSION_COOKIE_LIFETIME > 0) { ?>
<?php if (Config::get(Config::SESSION_COOKIE_LIFETIME) > 0) { ?>
<fieldset class="narrow">
<label> </label>
<label>
<input dojoType="dijit.form.CheckBox" name="remember_me" id="remember_me" type="checkbox">
<?php echo __("Remember me") ?>
<?= \Controls\checkbox_tag("remember_me") ?>
<?= __("Remember me") ?>
</label>
</fieldset>
@ -177,7 +176,7 @@
<fieldset class="align-right">
<label> </label>
<button dojoType="dijit.form.Button" type="submit" class="alt-primary"><?php echo __('Log in') ?></button>
<?= \Controls\submit_tag(__('Log in')) ?>
</fieldset>
</form>
@ -185,7 +184,7 @@
<div class="footer">
<a href="https://tt-rss.org/">Tiny Tiny RSS</a>
&copy; 2005&ndash;<?php echo date('Y') ?> <a href="https://fakecake.org/">Andrew Dolgov</a>
&copy; 2005&ndash;<?= date('Y') ?> <a href="https://fakecake.org/">Andrew Dolgov</a>
</div>
</div>

@ -21,7 +21,7 @@
$sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE
table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'");
$sth->execute([DB_NAME]);
$sth->execute([Config::get(Config::DB_NAME)]);
$bad_tables = [];
@ -40,14 +40,12 @@
array_push($errors, "Configuration file not found. Looks like you forgot to copy config.php-dist to config.php and edit it.");
} else {
require_once "sanity_config.php";
if (!file_exists("config.php")) {
array_push($errors, "Please copy config.php-dist to config.php");
}
if (strpos(PLUGINS, "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via PLUGINS constant in config.php");
if (strpos(Config::get(Config::PLUGINS), "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via Config::get(Config::PLUGINS) constant in config.php");
}
if (function_exists('posix_getuid') && posix_getuid() == 0) {
@ -62,41 +60,25 @@
array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module.");
}
if (CONFIG_VERSION != EXPECTED_CONFIG_VERSION) {
array_push($errors, "Configuration file (config.php) has incorrect version. Update it with new options from config.php-dist and set CONFIG_VERSION to the correct value.");
}
if (!is_writable(CACHE_DIR . "/images")) {
array_push($errors, "Image cache is not writable (chmod -R 777 ".CACHE_DIR."/images)");
if (!is_writable(Config::get(Config::CACHE_DIR) . "/images")) {
array_push($errors, "Image cache is not writable (chmod -R 777 ".Config::get(Config::CACHE_DIR)."/images)");
}
if (!is_writable(CACHE_DIR . "/upload")) {
array_push($errors, "Upload cache is not writable (chmod -R 777 ".CACHE_DIR."/upload)");
if (!is_writable(Config::get(Config::CACHE_DIR) . "/upload")) {
array_push($errors, "Upload cache is not writable (chmod -R 777 ".Config::get(Config::CACHE_DIR)."/upload)");
}
if (!is_writable(CACHE_DIR . "/export")) {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".CACHE_DIR."/export)");
if (!is_writable(Config::get(Config::CACHE_DIR) . "/export")) {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".Config::get(Config::CACHE_DIR)."/export)");
}
if (GENERATED_CONFIG_CHECK != EXPECTED_CONFIG_VERSION) {
array_push($errors,
"Configuration option checker sanity_config.php is outdated, please recreate it using ./utils/regen_config_checks.sh");
}
foreach ($required_defines as $d) {
if (!defined($d)) {
array_push($errors,
"Required configuration file parameter $d is not defined in config.php. You might need to copy it from config.php-dist.");
}
}
if (SINGLE_USER_MODE && class_exists("PDO")) {
if (Config::get(Config::SINGLE_USER_MODE) && class_exists("PDO")) {
$pdo = Db::pdo();
$res = $pdo->query("SELECT id FROM ttrss_users WHERE id = 1");
if (!$res->fetch()) {
array_push($errors, "SINGLE_USER_MODE is enabled in config.php but default admin account is not found.");
array_push($errors, "Config::get(Config::SINGLE_USER_MODE) is enabled in config.php but default admin account is not found.");
}
}
@ -107,26 +89,26 @@
$ref_self_url_path = preg_replace("/\w+\.php$/", "", $ref_self_url_path);
}
if (SELF_URL_PATH == "http://example.org/tt-rss/") {
if (Config::get(Config::SELF_URL_PATH) == "http://example.org/tt-rss/") {
$hint = $ref_self_url_path ? "(possible value: <b>$ref_self_url_path</b>)" : "";
array_push($errors,
"Please set SELF_URL_PATH to the correct value for your server: $hint");
"Please set Config::get(Config::SELF_URL_PATH) to the correct value for your server: $hint");
}
if ($ref_self_url_path &&
(!defined('_SKIP_SELF_URL_PATH_CHECKS') || !_SKIP_SELF_URL_PATH_CHECKS) &&
SELF_URL_PATH != $ref_self_url_path && SELF_URL_PATH != mb_substr($ref_self_url_path, 0, mb_strlen($ref_self_url_path)-1)) {
Config::get(Config::SELF_URL_PATH) != $ref_self_url_path && Config::get(Config::SELF_URL_PATH) != mb_substr($ref_self_url_path, 0, mb_strlen($ref_self_url_path)-1)) {
array_push($errors,
"Please set SELF_URL_PATH to the correct value detected for your server: <b>$ref_self_url_path</b> (you're using: <b>" . SELF_URL_PATH . "</b>)");
"Please set Config::get(Config::SELF_URL_PATH) to the correct value detected for your server: <b>$ref_self_url_path</b> (you're using: <b>" . Config::get(Config::SELF_URL_PATH) . "</b>)");
}
}
if (!is_writable(ICONS_DIR)) {
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".ICONS_DIR.").\n");
if (!is_writable(Config::get(Config::ICONS_DIR))) {
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".Config::get(Config::ICONS_DIR).").\n");
}
if (!is_writable(LOCK_DIRECTORY)) {
array_push($errors, "LOCK_DIRECTORY defined in config.php is not writable (chmod -R 777 ".LOCK_DIRECTORY.").\n");
if (!is_writable(Config::get(Config::LOCK_DIRECTORY))) {
array_push($errors, "Config::get(Config::LOCK_DIRECTORY) defined in config.php is not writable (chmod -R 777 ".Config::get(Config::LOCK_DIRECTORY).").\n");
}
if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) {
@ -137,14 +119,6 @@
array_push($errors, "PHP support for JSON is required, but was not found.");
}
if (DB_TYPE == "mysql" && !function_exists("mysqli_connect")) {
array_push($errors, "PHP support for MySQL is required for configured DB_TYPE in config.php.");
}
if (DB_TYPE == "pgsql" && !function_exists("pg_connect")) {
array_push($errors, "PHP support for PostgreSQL is required for configured DB_TYPE in config.php");
}
if (!class_exists("PDO")) {
array_push($errors, "PHP support for PDO is required but was not found.");
}
@ -169,7 +143,7 @@
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
}
if (DB_TYPE == "mysql") {
if (Config::get(Config::DB_TYPE) == "mysql") {
$bad_tables = check_mysql_tables();
if (count($bad_tables) > 0) {

@ -1,3 +0,0 @@
<?php # This file has been generated at: Fri Feb 12 15:56:39 MSK 2021
define('GENERATED_CONFIG_CHECK', 26);
$required_defines = array( 'DB_TYPE', 'DB_HOST', 'DB_USER', 'DB_NAME', 'DB_PASS', 'MYSQL_CHARSET', 'SELF_URL_PATH', 'SINGLE_USER_MODE', 'SIMPLE_UPDATE_MODE', 'PHP_EXECUTABLE', 'LOCK_DIRECTORY', 'CACHE_DIR', 'ICONS_DIR', 'ICONS_URL', 'AUTH_AUTO_CREATE', 'AUTH_AUTO_LOGIN', 'FORCE_ARTICLE_PURGE', 'SESSION_COOKIE_LIFETIME', 'SMTP_FROM_NAME', 'SMTP_FROM_ADDRESS', 'DIGEST_SUBJECT', 'CHECK_FOR_UPDATES', 'ENABLE_GZIP_OUTPUT', 'PLUGINS', 'LOG_DESTINATION', 'CONFIG_VERSION'); ?>

@ -1,31 +1,29 @@
<?php
// Original from http://www.daniweb.com/code/snippet43.html
namespace Sessions;
require_once "config.php";
require_once "classes/db.php";
require_once "autoload.php";
require_once "functions.php";
require_once "errorhandler.php";
require_once "lib/accept-to-gettext.php";
require_once "lib/gettext/gettext.inc.php";
$session_expire = min(2147483647 - time() - 1, max(SESSION_COOKIE_LIFETIME, 86400));
$session_name = (!defined('TTRSS_SESSION_NAME')) ? "ttrss_sid" : TTRSS_SESSION_NAME;
$session_expire = min(2147483647 - time() - 1, max(\Config::get(\Config::SESSION_COOKIE_LIFETIME), 86400));
$session_name = \Config::get(\Config::SESSION_NAME);
if (is_server_https()) {
ini_set("session.cookie_secure", true);
ini_set("session.cookie_secure", "true");
}
ini_set("session.gc_probability", 75);
ini_set("session.gc_probability", "75");
ini_set("session.name", $session_name);
ini_set("session.use_only_cookies", true);
ini_set("session.use_only_cookies", "true");
ini_set("session.gc_maxlifetime", $session_expire);
ini_set("session.cookie_lifetime", 0);
ini_set("session.cookie_lifetime", "0");
function session_get_schema_version() {
global $schema_version;
if (!$schema_version) {
$row = Db::pdo()->query("SELECT schema_version FROM ttrss_version")->fetch();
$row = \Db::pdo()->query("SELECT schema_version FROM ttrss_version")->fetch();
$version = $row["schema_version"];
@ -37,18 +35,18 @@
}
function validate_session() {
if (SINGLE_USER_MODE) return true;
if (\Config::get(\Config::SINGLE_USER_MODE)) return true;
if (isset($_SESSION["ref_schema_version"]) && $_SESSION["ref_schema_version"] != session_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 (!defined('_SESSION_SKIP_UA_CHECKS') && $_SESSION["user_agent"] != sha1($_SERVER['HTTP_USER_AGENT'])) {
if ($_SESSION["user_agent"] != sha1($_SERVER['HTTP_USER_AGENT'])) {
$_SESSION["login_error_msg"] = __("Session failed to validate (UA changed).");
return false;
}
@ -87,7 +85,7 @@
function ttrss_read ($id){
global $session_expire;
$sth = Db::pdo()->prepare("SELECT data FROM ttrss_sessions WHERE id=?");
$sth = \Db::pdo()->prepare("SELECT data FROM ttrss_sessions WHERE id=?");
$sth->execute([$id]);
if ($row = $sth->fetch()) {
@ -96,7 +94,7 @@
} else {
$expire = time() + $session_expire;
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
$sth = \Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
VALUES (?, '', ?)");
$sth->execute([$id, $expire]);
@ -112,14 +110,14 @@
$data = base64_encode($data);
$expire = time() + $session_expire;
$sth = Db::pdo()->prepare("SELECT id FROM ttrss_sessions WHERE id=?");
$sth = \Db::pdo()->prepare("SELECT id FROM ttrss_sessions WHERE id=?");
$sth->execute([$id]);
if ($row = $sth->fetch()) {
$sth = Db::pdo()->prepare("UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?");
$sth = \Db::pdo()->prepare("UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?");
$sth->execute([$data, $expire, $id]);
} else {
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
$sth = \Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
VALUES (?, ?, ?)");
$sth->execute([$id, $data, $expire]);
}
@ -132,22 +130,23 @@
}
function ttrss_destroy($id) {
$sth = Db::pdo()->prepare("DELETE FROM ttrss_sessions WHERE id = ?");
$sth = \Db::pdo()->prepare("DELETE FROM ttrss_sessions WHERE id = ?");
$sth->execute([$id]);
return true;
}
function ttrss_gc ($expire) {
Db::pdo()->query("DELETE FROM ttrss_sessions WHERE expire < " . time());
\Db::pdo()->query("DELETE FROM ttrss_sessions WHERE expire < " . time());
return true;
}
if (!SINGLE_USER_MODE /* && DB_TYPE == "pgsql" */) {
session_set_save_handler("ttrss_open",
"ttrss_close", "ttrss_read", "ttrss_write",
"ttrss_destroy", "ttrss_gc");
if (!\Config::get(\Config::SINGLE_USER_MODE)) {
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');
}

@ -1,9 +1,4 @@
<?php
if (!file_exists("config.php")) {
print "<b>Fatal Error</b>: You forgot to copy
<b>config.php-dist</b> to <b>config.php</b> and edit it.\n";
exit;
}
// we need a separate check here because functions.php might get parsed
// incorrectly before 5.3 because of :: syntax.
@ -12,15 +7,13 @@
exit;
}
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
get_include_path());
require_once "autoload.php";
require_once "sessions.php";
require_once "functions.php";
require_once "sanity_check.php";
require_once "config.php";
require_once "db-prefs.php";
if (!init_plugins()) return;
@ -42,12 +35,12 @@
}
} ?>
<?php if (theme_exists(LOCAL_OVERRIDE_STYLESHEET)) {
echo stylesheet_tag(get_theme_path(LOCAL_OVERRIDE_STYLESHEET));
<?php if (theme_exists(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET))) {
echo stylesheet_tag(get_theme_path(Config::get(Config::LOCAL_OVERRIDE_STYLESHEET)));
} ?>
<script type="text/javascript">
const __csrf_token = "<?php echo $_SESSION["csrf_token"]; ?>";
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
</script>
<?php UserHelper::print_user_stylesheet() ?>
@ -68,7 +61,7 @@
<script>
dojoConfig = {
async: true,
cacheBust: "<?php echo get_scripts_timestamp(); ?>",
cacheBust: "<?= get_scripts_timestamp(); ?>",
packages: [
{ name: "fox", location: "../../js" },
]
@ -76,13 +69,10 @@
</script>
<?php
foreach (array("lib/prototype.js",
"lib/scriptaculous/scriptaculous.js?load=effects,controls",
"lib/dojo/dojo.js",
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"js/tt-rss.js",
"js/common.js",
"errors.php?mode=js") as $jsfile) {
"js/common.js"] as $jsfile) {
echo javascript_tag($jsfile);
@ -132,7 +122,7 @@
<div id="overlay" style="display : block">
<div id="overlay_inner">
<?php echo __("Loading, please wait...") ?>
<?= __("Loading, please wait...") ?>
<div dojoType="dijit.ProgressBar" places="0" style="width : 300px" id="loading_bar"
progress="0" maximum="100">
</div>
@ -147,7 +137,7 @@
<div id="feeds-holder" dojoType="dijit.layout.ContentPane" region="leading" style="width : 20%" splitter="true">
<div id="feedlistLoading">
<img src='images/indicator_tiny.gif'/>
<?php echo __("Loading, please wait..."); ?></div>
<?= __("Loading, please wait..."); ?></div>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_FEED_TREE, function ($result) {
echo $result;
@ -161,13 +151,13 @@
<div id="toolbar" dojoType="fox.Toolbar">
<i class="material-icons net-alert" style="display : none"
title="<?php echo __("Communication problem with server.") ?>">error_outline</i>
title="<?= __("Communication problem with server.") ?>">error_outline</i>
<i class="material-icons log-alert" style="display : none" onclick="App.openPreferences('system')"
title="<?php echo __("Recent entries found in event log.") ?>">warning</i>
title="<?= __("Recent entries found in event log.") ?>">warning</i>
<i id="updates-available" class="material-icons icon-new-version" style="display : none"
title="<?php echo __('Updates are available from Git.') ?>">new_releases</i>
title="<?= __('Updates are available from Git.') ?>">new_releases</i>
<?php
@ -176,32 +166,32 @@
});
?>
<form id="toolbar-headlines" action="" style="order : 10" onsubmit='return false'>
<div id="toolbar-headlines" dojoType="fox.Toolbar" style="order : 10">
</form>
</div>
<form id="toolbar-main" action="" style="order : 20" onsubmit='return false'>
<select name="view_mode" title="<?php echo __('Show articles') ?>"
<select name="view_mode" title="<?= __('Show articles') ?>"
onchange="App.onViewModeChanged()"
dojoType="fox.form.Select">
<option selected="selected" value="adaptive"><?php echo __('Adaptive') ?></option>
<option value="all_articles"><?php echo __('All Articles') ?></option>
<option value="marked"><?php echo __('Starred') ?></option>
<option value="published"><?php echo __('Published') ?></option>
<option value="unread"><?php echo __('Unread') ?></option>
<option value="has_note"><?php echo __('With Note') ?></option>
<!-- <option value="noscores"><?php echo __('Ignore Scoring') ?></option> -->
<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="<?php echo __('Sort articles') ?>"
<select title="<?= __('Sort articles') ?>"
onchange="App.onViewModeChanged()"
dojoType="fox.form.Select" name="order_by">
<option selected="selected" value="default"><?php echo __('Default') ?></option>
<option value="feed_dates"><?php echo __('Newest first') ?></option>
<option value="date_reverse"><?php echo __('Oldest first') ?></option>
<option value="title"><?php echo __('Title') ?></option>
<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) {
@ -213,16 +203,16 @@
</select>
<div dojoType="fox.form.ComboButton" onclick="Feeds.catchupCurrent()">
<span><?php echo __('Mark as read') ?></span>
<span><?= __('Mark as read') ?></span>
<div dojoType="dijit.DropDownMenu">
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1day')">
<?php echo __('Older than one day') ?>
<?= __('Older than one day') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('1week')">
<?php echo __('Older than one week') ?>
<?= __('Older than one week') ?>
</div>
<div dojoType="dijit.MenuItem" onclick="Feeds.catchupCurrent('2week')">
<?php echo __('Older than two weeks') ?>
<?= __('Older than two weeks') ?>
</div>
</div>
</div>
@ -237,21 +227,21 @@
});
?>
<div dojoType="fox.form.DropDownButton" class="action-button" title="<?php echo __('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')"><?php echo __('Preferences...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcSearch')"><?php echo __('Search...') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?php echo __('Feed actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcAddFeed')"><?php echo __('Subscribe to feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcEditFeed')"><?php echo __('Edit this feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcRemoveFeed')"><?php echo __('Unsubscribe') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?php echo __('All feeds:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcCatchupAll')"><?php echo __('Mark as read') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcShowOnlyUnread')"><?php echo __('(Un)hide read feeds') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?php echo __('Other actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcToggleWidescreen')"><?php echo __('Toggle widescreen mode') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcHKhelp')"><?php echo __('Keyboard shortcuts help') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcPrefs')"><?= __('Preferences...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcSearch')"><?= __('Search...') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?= __('Feed actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcAddFeed')"><?= __('Subscribe to feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcEditFeed')"><?= __('Edit this feed...') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcRemoveFeed')"><?= __('Unsubscribe') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?= __('All feeds:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcCatchupAll')"><?= __('Mark as read') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcShowOnlyUnread')"><?= __('(Un)hide read feeds') ?></div>
<div dojoType="dijit.MenuItem" disabled="1"><?= __('Other actions:') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcToggleWidescreen')"><?= __('Toggle widescreen mode') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcHKhelp')"><?= __('Keyboard shortcuts help') ?></div>
<?php
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_ACTION_ITEM, function ($result) {
@ -260,7 +250,7 @@
?>
<?php if (empty($_SESSION["hide_logout"])) { ?>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcLogout')"><?php echo __('Logout') ?></div>
<div dojoType="dijit.MenuItem" onclick="App.onActionSelected('qmcLogout')"><?= __('Logout') ?></div>
<?php } ?>
</div>
</div>
@ -271,7 +261,7 @@
<div id="headlines-frame" dojoType="dijit.layout.ContentPane" tabindex="0"
region="center">
<div id="headlinesInnerContainer">
<div class="whiteBox"><?php echo __('Loading, please wait...') ?></div>
<div class="whiteBox"><?= __('Loading, please wait...') ?></div>
</div>
</div>
<div id="content-insert" dojoType="dijit.layout.ContentPane" region="bottom"

@ -1,9 +1,9 @@
'use strict';
/* eslint-disable new-cap */
/* global __, Article, Ajax, Headlines, Filters, fox */
/* global xhrPost, xhrJson, dojo, dijit, PluginHost, Notify, $$, Feeds, Cookie */
/* global CommonDialogs, Plugins, Effect */
/* global __, Article, Headlines, Filters, fox */
/* global xhr, dojo, dijit, PluginHost, Notify, Feeds, Cookie */
/* global CommonDialogs, Plugins */
const App = {
_initParams: [],
@ -18,8 +18,53 @@ const App = {
is_prefs: false,
LABEL_BASE_INDEX: -1024,
FormFields: {
hidden: function(name, value) {
return `<input dojoType="dijit.form.TextBox" style="display : none" name="${name}" value="${App.escapeHtml(value)}"></input>`
attributes_to_string: function(attributes) {
return Object.keys(attributes).map((k) =>
`${App.escapeHtml(k)}="${App.escapeHtml(attributes[k])}"`)
.join(" ");
},
hidden_tag: function(name, value, attributes = {}, id = "") {
return `<input id="${App.escapeHtml(id)}" dojoType="dijit.form.TextBox" ${this.attributes_to_string(attributes)}
style="display : none" name="${name}" value="${App.escapeHtml(value)}"></input>`
},
// allow html inside because of icons
button_tag: function(value, type, attributes = {}) {
return `<button dojoType="dijit.form.Button" ${this.attributes_to_string(attributes)}
type="${type}">${value}</button>`
},
icon: function(icon, attributes = {}) {
return `<i class="material-icons" ${this.attributes_to_string(attributes)}>${icon}</i>`;
},
submit_tag: function(value, attributes = {}) {
return this.button_tag(value, "submit", {...{class: "alt-primary"}, ...attributes});
},
cancel_dialog_tag: function(value, attributes = {}) {
return this.button_tag(value, "", {...{onclick: "App.dialogOf(this).hide()"}, ...attributes});
},
checkbox_tag: function(name, checked = false, value = "", attributes = {}, id = "") {
return `<input dojoType="dijit.form.CheckBox" type="checkbox" name="${App.escapeHtml(name)}"
${checked ? "checked" : ""}
${value ? `value="${App.escapeHtml(value)}"` : ""}
${this.attributes_to_string(attributes)} id="${App.escapeHtml(id)}">`
},
select_tag: function(name, value, values = [], attributes = {}, id = "") {
return `
<select name="${name}" dojoType="fox.form.Select" id="${App.escapeHtml(id)}" ${this.attributes_to_string(attributes)}>
${values.map((v) =>
`<option ${v == value ? 'selected="selected"' : ''} value="${App.escapeHtml(v)}">${App.escapeHtml(v)}</option>`
).join("")}
</select>
`
},
select_hash: function(name, value, values = {}, attributes = {}, id = "") {
return `
<select name="${name}" dojoType="fox.form.Select" id="${App.escapeHtml(id)}" ${this.attributes_to_string(attributes)}>
${Object.keys(values).map((vk) =>
`<option ${vk == value ? 'selected="selected"' : ''} value="${App.escapeHtml(vk)}">${App.escapeHtml(values[vk])}</option>`
).join("")}
</select>
`
}
},
Scrollable: {
@ -53,7 +98,25 @@ const App = {
return elem.offsetTop + elem.offsetHeight <= ctr.scrollTop + ctr.offsetHeight &&
elem.offsetTop >= ctr.scrollTop;
}
},
scrollTo: function (elem, ctr, params = {}) {
const force_to_top = params.force_to_top || false;
if (!elem || !ctr) return;
if (force_to_top || !App.Scrollable.fitsInContainer(elem, ctr)) {
ctr.scrollTop = elem.offsetTop;
}
}
},
byId: function(id) {
return document.getElementById(id);
},
find: function(query) {
return document.querySelector(query)
},
findAll: function(query) {
return document.querySelectorAll(query);
},
dialogOf: function (elem) {
@ -62,6 +125,9 @@ const App = {
return dijit.getEnclosingWidget(elem.closest('.dijitDialog'));
},
getPhArgs(plugin, method, args = {}) {
return {...{op: "pluginhandler", plugin: plugin, method: method}, ...args};
},
label_to_feed_id: function(label) {
return this.LABEL_BASE_INDEX - 1 - Math.abs(label);
},
@ -83,21 +149,20 @@ const App = {
}
},
setupNightModeDetection: function(callback) {
if (!$("theme_css")) {
if (!App.byId("theme_css")) {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
try {
mql.addEventListener("change", () => {
this.nightModeChanged(mql.matches, $("theme_auto_css"));
this.nightModeChanged(mql.matches, App.byId("theme_auto_css"));
});
} catch (e) {
console.warn("exception while trying to set MQL event listener");
}
const link = new Element("link", {
rel: "stylesheet",
id: "theme_auto_css"
});
const link = document.createElement("link");
link.rel = "stylesheet";
link.id = "theme_auto_css";
if (callback) {
link.onload = function() {
@ -119,27 +184,6 @@ const App = {
if (callback) callback();
}
},
enableCsrfSupport: function() {
const _this = this;
Ajax.Base.prototype.initialize = Ajax.Base.prototype.initialize.wrap(
function (callOriginal, options) {
if (_this.getInitParam("csrf_token") != undefined) {
Object.extend(options, options || { });
if (Object.isString(options.parameters))
options.parameters = options.parameters.toQueryParams();
else if (Object.isHash(options.parameters))
options.parameters = options.parameters.toObject();
options.parameters["csrf_token"] = _this.getInitParam("csrf_token");
}
return callOriginal(options);
}
);
},
postCurrentWindow: function(target, params) {
const form = document.createElement("form");
@ -188,8 +232,13 @@ const App = {
}
},
urlParam: function(param) {
return String(window.location.href).parseQuery()[param];
urlParam: function(name) {
try {
const results = new RegExp('[?&]' + name + '=([^&#]*)').exec(window.location.href);
return decodeURIComponent(results[1].replace(/\+/g, " ")) || 0;
} catch (e) {
return 0;
}
},
next_seq: function() {
this._rpc_seq += 1;
@ -205,7 +254,7 @@ const App = {
dijit.byId("loading_bar").update({progress: this._loading_progress});
if (this._loading_progress >= 90) {
$("overlay").hide();
App.byId("overlay").hide();
}
},
@ -236,7 +285,7 @@ const App = {
if (!this.hotkey_prefix && hotkeys_map[0].indexOf(keychar) != -1) {
this.hotkey_prefix = keychar;
$("cmdline").innerHTML = keychar;
App.byId("cmdline").innerHTML = keychar;
Element.show("cmdline");
window.clearTimeout(this.hotkey_prefix_timeout);
@ -285,165 +334,154 @@ const App = {
cleanupMemory: function(root) {
const dijits = dojo.query("[widgetid]", dijit.byId(root).domNode).map(dijit.byNode);
dijits.each(function (d) {
dijits.forEach(function (d) {
dojo.destroy(d.domNode);
});
$$("#" + root + " *").each(function (i) {
App.findAll("#" + root + " *").forEach(function (i) {
i.parentNode ? i.parentNode.removeChild(i) : true;
});
},
// htmlspecialchars()-alike for headlines data-content attribute
escapeHtml: function(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
escapeHtml: function(p) {
if (typeof p == "string") {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return p.replace(/[&<>"']/g, function(m) { return map[m]; });
} else {
return p;
}
},
// http://stackoverflow.com/questions/6251937/how-to-get-selecteduser-highlighted-text-in-contenteditable-element-and-replac
getSelectedText: function() {
let text = "";
if (typeof window.getSelection != "undefined") {
const sel = window.getSelection();
if (sel.rangeCount) {
const container = document.createElement("div");
for (let i = 0, len = sel.rangeCount; i < len; ++i) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
text = container.innerHTML;
}
} else if (typeof document.selection != "undefined") {
if (document.selection.type == "Text") {
text = document.selection.createRange().textText;
}
}
return text.stripTags();
},
displayIfChecked: function(checkbox, elemId) {
if (checkbox.checked) {
Effect.Appear(elemId, {duration : 0.5});
Element.show(elemId);
} else {
Effect.Fade(elemId, {duration : 0.5});
Element.hide(elemId);
}
},
helpDialog: function(topic) {
xhrPost("backend.php", {op: "backend", method: "help", topic: topic}, (transport) => {
hotkeyHelp: function() {
xhr.post("backend.php", {op: "rpc", method: "hotkeyHelp"}, (reply) => {
const dialog = new fox.SingleUseDialog({
title: __("Help"),
content: transport.responseText,
title: __("Keyboard shortcuts"),
content: reply,
});
dialog.show();
});
},
handleRpcJson: function(transport) {
const netalert = $$("#toolbar .net-alert")[0];
try {
const reply = JSON.parse(transport.responseText);
if (reply) {
const error = reply['error'];
if (error) {
const code = error['code'];
const msg = error['message'];
console.warn("[handleRpcJson] received fatal error ", code, msg);
if (code != 0) {
/* global ERRORS */
this.Error.fatal(ERRORS[code], {info: msg, code: code});
return false;
}
}
const seq = reply['seq'];
if (seq && this.get_seq() != seq) {
console.log("[handleRpcJson] sequence mismatch: ", seq, '!=', this.get_seq());
return true;
}
const message = reply['message'];
if (message == "UPDATE_COUNTERS") {
console.log("need to refresh counters...");
Feeds.requestCounters(true);
}
handleRpcJson: function(reply) {
const counters = reply['counters'];
const netalert = App.find(".net-alert");
if (counters)
Feeds.parseCounters(counters);
if (reply) {
const error = reply['error'];
const seq = reply['seq'];
const message = reply['message'];
const counters = reply['counters'];
const runtime_info = reply['runtime-info'];
const runtime_info = reply['runtime-info'];
if (error && error.code && error.code != App.Error.E_SUCCESS) {
console.warn("handleRpcJson: fatal error", error);
this.Error.fatal(error.code);
return false;
}
if (runtime_info)
this.parseRuntimeInfo(runtime_info);
if (seq && this.get_seq() != seq) {
console.warn("handleRpcJson: sequence mismatch: ", seq, '!=', this.get_seq());
return false;
}
if (netalert) netalert.hide();
// not in preferences
if (typeof Feeds != "undefined") {
if (message == "UPDATE_COUNTERS") {
console.log("need to refresh counters for", reply.feeds);
Feeds.requestCounters(reply.feeds);
}
return reply;
if (counters)
Feeds.parseCounters(counters);
}
} else {
if (netalert) netalert.show();
if (runtime_info)
this.parseRuntimeInfo(runtime_info);
Notify.error("Communication problem with server.");
}
if (netalert) netalert.hide();
} catch (e) {
if (netalert) netalert.show();
return true;
} else {
if (netalert) netalert.show();
Notify.error("Communication problem with server.");
Notify.error("Communication problem with server.");
console.error(e);
return false;
}
return false;
},
parseRuntimeInfo: function(data) {
for (const k in data) {
if (data.hasOwnProperty(k)) {
const v = data[k];
Object.keys(data).forEach((k) => {
const v = data[k];
console.log("RI:", k, "=>", v);
console.log("RI:", k, "=>", v);
if (k == "daemon_is_running" && v != 1) {
Notify.error("Update daemon is not running.", true);
return;
}
if (k == "daemon_is_running" && v != 1) {
Notify.error("Update daemon is not running.", true);
return;
}
if (k == "recent_log_events") {
const alert = $$(".log-alert")[0];
if (k == "recent_log_events") {
const alert = App.find(".log-alert");
if (alert) {
v > 0 ? alert.show() : alert.hide();
}
}
if (alert) {
v > 0 ? alert.show() : alert.hide();
}
}
if (k == "daemon_stamp_ok" && v != 1) {
Notify.error("Update daemon is not updating feeds.", true);
return;
}
if (k == "daemon_stamp_ok" && v != 1) {
Notify.error("Update daemon is not updating feeds.", true);
return;
}
if (k == "max_feed_id" || k == "num_feeds") {
if (this.getInitParam(k) != v) {
console.log("feed count changed, need to reload feedlist.");
Feeds.reload();
}
}
if (typeof Feeds != "undefined") {
if (k == "max_feed_id" || k == "num_feeds") {
if (this.getInitParam(k) && this.getInitParam(k) != v) {
console.log("feed count changed, need to reload feedlist:", this.getInitParam(k), v);
Feeds.reload();
}
}
}
this.setInitParam(k, v);
}
}
this.setInitParam(k, v);
});
PluginHost.run(PluginHost.HOOK_RUNTIME_INFO_LOADED, data);
},
backendSanityCallback: function(transport) {
const reply = JSON.parse(transport.responseText);
if (!reply) {
this.Error.fatal(ERRORS[3], {info: transport.responseText});
return;
}
if (reply['error']) {
const code = reply['error']['code'];
if (code && code != 0) {
return this.Error.fatal(ERRORS[code],
{code: code, info: reply['error']['message']});
}
}
backendSanityCallback: function(reply) {
console.log("sanity check ok");
const params = reply['init-params'];
@ -451,39 +489,36 @@ const App = {
if (params) {
console.log('reading init-params...');
for (const k in params) {
if (params.hasOwnProperty(k)) {
switch (k) {
case "label_base_index":
this.LABEL_BASE_INDEX = parseInt(params[k]);
break;
case "cdm_auto_catchup":
if (params[k] == 1) {
const hl = $("headlines-frame");
if (hl) hl.addClassName("auto_catchup");
}
break;
case "hotkeys":
// filter mnemonic definitions (used for help panel) from hotkeys map
// i.e. *(191)|Ctrl-/ -> *(191)
{
const tmp = [];
for (const sequence in params[k][1]) {
if (params[k][1].hasOwnProperty(sequence)) {
const filtered = sequence.replace(/\|.*$/, "");
tmp[filtered] = params[k][1][sequence];
}
}
params[k][1] = tmp;
}
break;
}
Object.keys(params).forEach((k) => {
switch (k) {
case "label_base_index":
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");
}
break;
case "hotkeys":
// filter mnemonic definitions (used for help panel) from hotkeys map
// i.e. *(191)|Ctrl-/ -> *(191)
{
const tmp = [];
Object.keys(params[k][1]).forEach((sequence) => {
const filtered = sequence.replace(/\|.*$/, "");
tmp[filtered] = params[k][1][sequence];
});
params[k][1] = tmp;
}
break;
}
console.log("IP:", k, "=>", params[k]);
this.setInitParam(k, params[k]);
}
}
console.log("IP:", k, "=>", params[k]);
this.setInitParam(k, params[k]);
});
// PluginHost might not be available on non-index pages
if (typeof PluginHost !== 'undefined')
@ -493,69 +528,68 @@ const App = {
this.initSecondStage();
},
Error: {
fatal: function (error, params) {
params = params || {};
if (params.code) {
if (params.code == 6) {
window.location.href = "index.php";
return;
} else if (params.code == 5) {
window.location.href = "public.php?op=dbupdate";
return;
}
}
E_SUCCESS: "E_SUCCESS",
E_UNAUTHORIZED: "E_UNAUTHORIZED",
E_SCHEMA_MISMATCH: "E_SCHEMA_MISMATCH",
fatal: function (error, params = {}) {
if (error == App.Error.E_UNAUTHORIZED) {
window.location.href = "index.php";
return;
} else if (error == App.Error.E_SCHEMA_MISMATCH) {
window.location.href = "public.php?op=dbupdate";
return;
}
return this.report(error,
Object.extend({title: __("Fatal error")}, params));
return this.report(__("Fatal error: %s").replace("%s", error),
{...{title: __("Fatal error")}, ...params});
},
report: function(error, params) {
params = params || {};
report: function(error, params = {}) {
if (!error) return;
console.error("[Error.report]", error, params);
console.error("error.report:", error, params);
const message = params.message ? params.message : error.toString();
try {
xhrPost("backend.php",
xhr.post("backend.php",
{op: "rpc", method: "log",
file: params.filename ? params.filename : error.fileName,
line: params.lineno ? params.lineno : error.lineNumber,
msg: message,
context: error.stack},
(transport) => {
console.warn("[Error.report] log response", transport.responseText);
(reply) => {
console.warn("[Error.report] log response", reply);
});
} catch (re) {
console.error("[Error.report] exception while saving logging error on server", re);
}
try {
let stack_msg = "";
if (error.stack)
stack_msg += `<div><b>Stack trace:</b></div>
<textarea name="stack" readonly="1">${error.stack}</textarea>`;
if (params.info)
stack_msg += `<div><b>Additional information:</b></div>
<textarea name="stack" readonly="1">${params.info}</textarea>`;
const content = `<div class="error-contents">
<p class="message">${message}</p>
${stack_msg}
<div class="dlgButtons">
<button dojoType="dijit.form.Button"
onclick="dijit.byId('exceptionDlg').hide()">${__('Close this window')}</button>
</div>
</div>`;
const dialog = new fox.SingleUseDialog({
id: "exceptionDlg",
title: params.title || __("Unhandled exception"),
content: content
content: `
<div class='exception-contents'>
<h3>${message}</h3>
<header>${__('Stack trace')}</header>
<section>
<textarea readonly='readonly'>${error.stack}</textarea>
</section>
${params && params.info ?
`
<header>${__('Additional information')}</header>
<section>
<textarea readonly='readonly'>${params.info}</textarea>
</section>
` : ''}
</div>
<footer class='text-center'>
<button dojoType="dijit.form.Button" class='alt-primary' type='submit'>
${__('Close this window')}
</button>
</footer>
</div>`
});
dialog.show();
@ -575,6 +609,10 @@ const App = {
isPrefs() {
return this.is_prefs;
},
audioCanPlay: function(ctype) {
const a = document.createElement('audio');
return a.canPlayType(ctype);
},
init: function(parser, is_prefs) {
this.is_prefs = is_prefs;
window.onerror = this.Error.onWindowError;
@ -591,24 +629,17 @@ const App = {
this.setLoadingProgress(30);
this.initHotkeyActions();
this.enableCsrfSupport();
const a = document.createElement('audio');
const hasAudio = !!a.canPlayType;
const hasSandbox = "sandbox" in document.createElement("iframe");
const hasMp3 = !!(a.canPlayType && a.canPlayType('audio/mpeg;').replace(/no/, ''));
const clientTzOffset = new Date().getTimezoneOffset() * 60;
const params = {
op: "rpc", method: "sanityCheck", hasAudio: hasAudio,
hasMp3: hasMp3,
clientTzOffset: clientTzOffset,
hasSandbox: hasSandbox
op: "rpc",
method: "sanityCheck",
clientTzOffset: new Date().getTimezoneOffset() * 60,
hasSandbox: "sandbox" in document.createElement("iframe")
};
xhrPost("backend.php", params, (transport) => {
xhr.json("backend.php", params, (reply) => {
try {
this.backendSanityCallback(transport);
this.backendSanityCallback(reply);
} catch (e) {
this.Error.report(e);
}
@ -618,7 +649,7 @@ const App = {
checkBrowserFeatures: function() {
let errorMsg = "";
['MutationObserver'].each(function(wf) {
['MutationObserver'].forEach(function(wf) {
if (!(wf in window)) {
errorMsg = `Browser feature check failed: <code>window.${wf}</code> not found.`;
throw new Error(errorMsg);
@ -631,6 +662,11 @@ const App = {
return errorMsg == "";
},
updateRuntimeInfo: function() {
xhr.json("backend.php", {op: "rpc", method: "getruntimeinfo"}, () => {
// handled by xhr.json()
});
},
initSecondStage: function() {
document.onkeydown = (event) => this.hotkeyHandler(event);
@ -648,14 +684,18 @@ const App = {
if (tab) {
dijit.byId("pref-tabs").selectChild(tab);
switch (this.urlParam('method')) {
case "editfeed":
window.setTimeout(() => {
CommonDialogs.editFeed(this.urlParam('methodparam'))
}, 100);
break;
default:
console.warn("initSecondStage, unknown method:", this.urlParam("method"));
const method = this.urlParam("method");
if (method) {
switch (method) {
case "editfeed":
window.setTimeout(() => {
CommonDialogs.editFeed(this.urlParam('methodparam'))
}, 100);
break;
default:
console.warn("initSecondStage, unknown method:", method);
}
}
}
} else {
@ -671,6 +711,7 @@ const App = {
dojo.connect(dijit.byId("pref-tabs"), "selectChild", function (elem) {
localStorage.setItem("ttrss:prefs-tab", elem.id);
App.updateRuntimeInfo();
});
} else {
@ -726,24 +767,28 @@ const App = {
}, 3600 * 1000);
}
console.log("second stage ok");
PluginHost.run(PluginHost.HOOK_INIT_COMPLETE, null);
}
if (!this.getInitParam("bw_limit"))
window.setInterval(() => {
App.updateRuntimeInfo();
}, 60 * 1000)
console.log("second stage ok");
},
checkForUpdates: function() {
console.log('checking for updates...');
xhrJson("backend.php", {op: 'rpc', method: 'checkforupdates'})
xhr.json("backend.php", {op: 'rpc', method: 'checkforupdates'})
.then((reply) => {
console.log('update reply', reply);
if (reply.id) {
$("updates-available").show();
App.byId("updates-available").show();
} else {
$("updates-available").hide();
App.byId("updates-available").hide();
}
});
},
@ -759,7 +804,7 @@ const App = {
onViewModeChanged: function() {
const view_mode = document.forms["toolbar-main"].view_mode.value;
$$("body")[0].setAttribute("view-mode", view_mode);
App.findAll("body")[0].setAttribute("view-mode", view_mode);
return Feeds.reloadCurrent('');
},
@ -798,8 +843,8 @@ const App = {
{width: Cookie.get("ttrss_ci_width") + "px" });
}
$("headlines-frame").setStyle({ borderBottomWidth: '0px' });
$("headlines-frame").addClassName("wide");
App.byId("headlines-frame").setStyle({ borderBottomWidth: '0px' });
App.byId("headlines-frame").addClassName("wide");
} else {
@ -814,8 +859,8 @@ const App = {
{height: Cookie.get("ttrss_ci_height") + "px" });
}
$("headlines-frame").setStyle({ borderBottomWidth: '1px' });
$("headlines-frame").removeClassName("wide");
App.byId("headlines-frame").setStyle({ borderBottomWidth: '1px' });
App.byId("headlines-frame").removeClassName("wide");
}
@ -823,13 +868,13 @@ const App = {
if (article_id) Article.view(article_id);
xhrPost("backend.php", {op: "rpc", method: "setpanelmode", wide: wide ? 1 : 0});
xhr.post("backend.php", {op: "rpc", method: "setpanelmode", wide: wide ? 1 : 0});
},
initHotkeyActions: function() {
if (this.is_prefs) {
this.hotkey_actions["feed_subscribe"] = () => {
CommonDialogs.quickAddFeed();
CommonDialogs.subscribeToFeed();
};
this.hotkey_actions["create_label"] = () => {
@ -841,7 +886,7 @@ const App = {
};
this.hotkey_actions["help_dialog"] = () => {
this.helpDialog("main");
this.hotkeyHelp();
};
} else {
@ -985,14 +1030,13 @@ const App = {
Feeds.toggleUnread();
};
this.hotkey_actions["feed_subscribe"] = () => {
CommonDialogs.quickAddFeed();
CommonDialogs.subscribeToFeed();
};
this.hotkey_actions["feed_debug_update"] = () => {
if (!Feeds.activeIsCat() && parseInt(Feeds.getActive()) > 0) {
//window.open("backend.php?op=feeds&method=update_debugger&feed_id=" + Feeds.getActive());
/* global __csrf_token */
App.postOpenWindow("backend.php", {op: "feeds", method: "update_debugger",
App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger",
feed_id: Feeds.getActive(), csrf_token: __csrf_token});
} else {
@ -1001,8 +1045,6 @@ const App = {
};
this.hotkey_actions["feed_debug_viewfeed"] = () => {
//Feeds.open({feed: Feeds.getActive(), is_cat: Feeds.activeIsCat(), viewfeed_debug: true});
App.postOpenWindow("backend.php", {op: "feeds", method: "view",
feed: Feeds.getActive(), timestamps: 1, debug: 1, cat: Feeds.activeIsCat(), csrf_token: __csrf_token});
};
@ -1022,7 +1064,7 @@ const App = {
Headlines.reverse();
};
this.hotkey_actions["feed_toggle_vgroup"] = () => {
xhrPost("backend.php", {op: "rpc", method: "togglepref", key: "VFEED_GROUP_BY_FEED"}, () => {
xhr.post("backend.php", {op: "rpc", method: "togglepref", key: "VFEED_GROUP_BY_FEED"}, () => {
Feeds.reloadCurrent();
})
};
@ -1055,7 +1097,7 @@ const App = {
this.hotkey_actions["select_article_cursor"] = () => {
const id = Article.getUnderPointer();
if (id) {
const row = $("RROW-" + id);
const row = App.byId(`RROW-${id}`);
if (row)
row.toggleClassName("Selected");
@ -1092,12 +1134,12 @@ const App = {
}
};
this.hotkey_actions["help_dialog"] = () => {
this.helpDialog("main");
this.hotkeyHelp();
};
this.hotkey_actions["toggle_combined_mode"] = () => {
const value = this.isCombinedMode() ? "false" : "true";
xhrPost("backend.php", {op: "rpc", method: "setpref", key: "COMBINED_DISPLAY_MODE", value: value}, () => {
xhr.post("backend.php", {op: "rpc", method: "setpref", key: "COMBINED_DISPLAY_MODE", value: value}, () => {
this.setInitParam("combined_display_mode",
!this.getInitParam("combined_display_mode"));
@ -1108,7 +1150,7 @@ const App = {
this.hotkey_actions["toggle_cdm_expanded"] = () => {
const value = this.getInitParam("cdm_expanded") ? "false" : "true";
xhrPost("backend.php", {op: "rpc", method: "setpref", key: "CDM_EXPANDED", value: value}, () => {
xhr.post("backend.php", {op: "rpc", method: "setpref", key: "CDM_EXPANDED", value: value}, () => {
this.setInitParam("cdm_expanded", !this.getInitParam("cdm_expanded"));
Headlines.renderAgain();
});
@ -1130,7 +1172,7 @@ const App = {
Feeds.search();
break;
case "qmcAddFeed":
CommonDialogs.quickAddFeed();
CommonDialogs.subscribeToFeed();
break;
case "qmcDigest":
window.location.href = "backend.php?op=digest";
@ -1182,7 +1224,7 @@ const App = {
}
break;
case "qmcHKhelp":
this.helpDialog("main");
this.hotkeyHelp()
break;
default:
console.log("quickMenuGo: unknown action: " + opid);

@ -1,7 +1,7 @@
'use strict'
/* eslint-disable no-new */
/* global __, ngettext, App, Headlines, xhrPost, xhrJson, dojo, dijit, PluginHost, Notify, $$, Ajax, fox */
/* global __, ngettext, App, Headlines, xhr, dojo, dijit, PluginHost, Notify, fox */
const Article = {
_scroll_reset_timeout: false,
@ -36,19 +36,19 @@ const Article = {
const score = prompt(__("Please enter new score for selected articles:"));
if (!isNaN(parseInt(score))) {
ids.each((id) => {
const row = $("RROW-" + id);
ids.forEach((id) => {
const row = App.byId(`RROW-${id}`);
if (row) {
row.setAttribute("data-score", score);
const pic = row.select(".icon-score")[0];
const pic = row.querySelector(".icon-score");
pic.innerHTML = Article.getScorePic(score);
pic.setAttribute("title", score);
["score-low", "score-high", "score-half-low", "score-half-high", "score-neutral"]
.each(function(scl) {
.forEach(function(scl) {
if (row.hasClassName(scl))
row.removeClassName(scl);
});
@ -63,7 +63,7 @@ const Article = {
}
},
setScore: function (id, pic) {
const row = pic.up("div[id*=RROW]");
const row = pic.closest("div[id*=RROW]");
if (row) {
const score_old = row.getAttribute("data-score");
@ -72,13 +72,13 @@ const Article = {
if (!isNaN(parseInt(score))) {
row.setAttribute("data-score", score);
const pic = row.select(".icon-score")[0];
const pic = row.querySelector(".icon-score");
pic.innerHTML = Article.getScorePic(score);
pic.setAttribute("title", score);
["score-low", "score-high", "score-half-low", "score-half-high", "score-neutral"]
.each(function(scl) {
.forEach(function(scl) {
if (row.hasClassName(scl))
row.removeClassName(scl);
});
@ -93,18 +93,8 @@ const Article = {
w.opener = null;
w.location = url;
},
/* popupOpenArticle: function(id) {
const w = window.open("",
"ttrss_article_popup",
"height=900,width=900,resizable=yes,status=no,location=no,menubar=no,directories=no,scrollbars=yes,toolbar=no");
if (w) {
w.opener = null;
w.location = "backend.php?op=article&method=view&mode=raw&html=1&zoom=1&id=" + id + "&csrf_token=" + App.getInitParam("csrf_token");
}
}, */
cdmUnsetActive: function (event) {
const row = $("RROW-" + Article.getActive());
const row = App.byId(`RROW-${Article.getActive()}`);
if (row) {
row.removeClassName("active");
@ -123,11 +113,13 @@ const Article = {
Article.setActive(0);
},
displayUrl: function (id) {
const query = {op: "rpc", method: "getlinktitlebyid", id: id};
const query = {op: "article", method: "getmetadatabyid", id: id};
xhrJson("backend.php", query, (reply) => {
xhr.json("backend.php", query, (reply) => {
if (reply && reply.link) {
prompt(__("Article URL:"), reply.link);
} else {
alert(__("No URL could be displayed for this article."));
}
});
},
@ -138,6 +130,77 @@ const Article = {
Headlines.toggleUnread(id, 0);
},
renderNote: function (id, note) {
return `<div class="article-note" data-note-for="${id}" style="display : ${note ? "" : "none"}">
${App.FormFields.icon('note')} <div onclick class='body'>${note ? App.escapeHtml(note) : ""}</div>
</div>`;
},
renderTags: function (id, tags) {
const tags_short = tags.length > 5 ? tags.slice(0, 5) : tags;
return `<span class="tags" title="${tags.join(", ")}" data-tags-for="${id}">
${tags_short.length > 0 ? tags_short.map((tag) => `
<a href="#" onclick="Feeds.open({feed: '${tag.trim()}'})" class="tag">${tag}</a>`
).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>`;
},
renderEnclosures: function (enclosures) {
return `
${enclosures.formatted}
${enclosures.can_inline ?
`<div class='attachments-inline'>
${enclosures.entries.map((enc) => {
if (!enclosures.inline_text_only) {
if (enc.content_type && enc.content_type.indexOf("image/") != -1) {
return `<p>
<img loading="lazy"
width="${enc.width ? enc.width : ''}"
height="${enc.height ? enc.height : ''}"
src="${App.escapeHtml(enc.content_url)}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"/>
</p>`
} else if (enc.content_type && enc.content_type.indexOf("audio/") != -1 && App.audioCanPlay(enc.content_type)) {
return `<p class='inline-player' title="${App.escapeHtml(enc.content_url)}">
<audio preload="none" controls="controls">
<source type="${App.escapeHtml(enc.content_type)}" src="${App.escapeHtml(enc.content_url)}"/>
</audio>
</p>
`;
} else {
return `<p>
<a target="_blank" href="${App.escapeHtml(enc.content_url)}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"
rel="noopener noreferrer">${App.escapeHtml(enc.content_url)}</a>
</p>`
}
} else {
return `<p>
<a target="_blank" href="${App.escapeHtml(enc.content_url)}"
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}"
rel="noopener noreferrer">${App.escapeHtml(enc.content_url)}</a>
</p>`
}
}).join("")}
</div>` : ''}
${enclosures.entries.length > 0 ?
`<div class="attachments" dojoType="fox.form.DropDownButton">
<span>${__('Attachments')}</span>
<div dojoType="dijit.Menu" style="display: none">
${enclosures.entries.map((enc) => `
<div onclick='Article.popupOpenUrl("${App.escapeHtml(enc.content_url)}")'
title="${App.escapeHtml(enc.title ? enc.title : enc.content_url)}" dojoType="dijit.MenuItem">
${enc.title ? enc.title : enc.filename}
</div>
`).join("")}
</div>
</div>` : ''}
`
},
render: function (article) {
App.cleanupMemory("content-insert");
@ -184,12 +247,14 @@ const Article = {
container.innerHTML = row.getAttribute("data-content").trim();
dojo.parser.parse(container);
// blank content element might screw up onclick selection and keyboard moving
if (container.textContent.length == 0)
container.innerHTML += "&nbsp;";
// in expandable mode, save content for later, so that we can pack unfocused rows back
if (App.isCombinedMode() && $("main").hasClassName("expandable"))
if (App.isCombinedMode() && App.byId("main").hasClassName("expandable"))
row.setAttribute("data-content-original", row.getAttribute("data-content"));
row.removeAttribute("data-content");
@ -230,16 +295,16 @@ const Article = {
<div class="comments">${comments}</div>
<div class="author">${hl.author}</div>
<i class="material-icons">label_outline</i>
<span id="ATSTR-${hl.id}">${hl.tags_str}</span>
${Article.renderTags(hl.id, hl.tags)}
&nbsp;<a title="${__("Edit tags for this article")}" href="#"
onclick="Article.editTags(${hl.id})">(+)</a>
<div class="buttons right">${hl.buttons}</div>
</div>
</div>
<div id="POSTNOTE-${hl.id}">${hl.note}</div>
${Article.renderNote(hl.id, hl.note)}
<div class="content" lang="${hl.lang ? hl.lang : 'en'}">
${hl.content}
${hl.enclosures}
${Article.renderEnclosures(hl.enclosures)}
</div>
</div>`;
@ -252,29 +317,41 @@ const Article = {
},
editTags: function (id) {
const dialog = new fox.SingleUseDialog({
id: "editTagsDlg",
title: __("Edit article Tags"),
content: __("Loading, please wait..."),
content: `
${App.FormFields.hidden_tag("id", id.toString())}
${App.FormFields.hidden_tag("op", "article")}
${App.FormFields.hidden_tag("method", "setArticleTags")}
<header class='horizontal'>
${__("Tags for this article (separated by commas):")}
</header>
<section>
<textarea dojoType='dijit.form.SimpleTextarea' rows='4' disabled='true'
id='tags_str' name='tags_str'></textarea>
<div class='autocomplete' id='tags_choices' style='display:none'></div>
</section>
<footer>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
`,
execute: function () {
if (this.validate()) {
Notify.progress("Saving article tags...", true);
xhrPost("backend.php", this.attr('value'), (transport) => {
xhr.json("backend.php", this.attr('value'), (data) => {
try {
Notify.close();
dialog.hide();
const data = JSON.parse(transport.responseText);
if (data) {
const id = data.id;
const tags = $("ATSTR-" + id);
const tooltip = dijit.byId("ATSTRTIP-" + id);
if (tags) tags.innerHTML = data.content;
if (tooltip) tooltip.attr('label', data.content_full);
}
Headlines.onTagsUpdated(data);
} catch (e) {
App.Error.report(e);
}
@ -286,25 +363,26 @@ const Article = {
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhrPost("backend.php", {op: "article", method: "editarticletags", param: id}, (transport) => {
dialog.attr('content', transport.responseText);
xhr.json("backend.php", {op: "article", method: "printArticleTags", id: id}, (reply) => {
dijit.getEnclosingWidget(App.byId("tags_str"))
.attr('value', reply.tags.join(", "))
.attr('disabled', false);
new Ajax.Autocompleter('tags_str', 'tags_choices',
/* new Ajax.Autocompleter("tags_str", "tags_choices",
"backend.php?op=article&method=completeTags",
{tokens: ',', paramName: "search"});
{tokens: ',', paramName: "search"}); */
});
});
dialog.show();
},
cdmMoveToId: function (id, params) {
params = params || {};
cdmMoveToId: function (id, params = {}) {
const force_to_top = params.force_to_top || false;
const ctr = $("headlines-frame");
const row = $("RROW-" + id);
const ctr = App.byId("headlines-frame");
const row = App.byId(`RROW-${id}`);
if (!row || !ctr) return;
@ -316,12 +394,12 @@ const Article = {
if (id != Article.getActive()) {
console.log("setActive", id, "was", Article.getActive());
$$("div[id*=RROW][class*=active]").each((row) => {
App.findAll("div[id*=RROW][class*=active]").forEach((row) => {
row.removeClassName("active");
Article.pack(row);
});
const row = $("RROW-" + id);
const row = App.byId(`RROW-${id}`);
if (row) {
Article.unpack(row);
@ -342,10 +420,10 @@ const Article = {
return 0;
},
scrollByPages: function (page_offset) {
App.Scrollable.scrollByPages($("content-insert"), page_offset);
App.Scrollable.scrollByPages(App.byId("content-insert"), page_offset);
},
scroll: function (offset) {
App.Scrollable.scroll($("content-insert"), offset);
App.Scrollable.scroll(App.byId("content-insert"), offset);
},
mouseIn: function (id) {
this.post_under_pointer = id;

@ -3,7 +3,7 @@
/* eslint-disable new-cap */
/* eslint-disable no-new */
/* global __, dojo, dijit, Notify, App, Feeds, $$, xhrPost, xhrJson, Tables, Effect, fox */
/* global __, dojo, dijit, Notify, App, Feeds, xhrPost, xhr, Tables, fox */
/* exported CommonDialogs */
const CommonDialogs = {
@ -11,89 +11,99 @@ const CommonDialogs = {
const dialog = dijit.byId("infoBox");
if (dialog) dialog.hide();
},
removeFeedIcon: function(id) {
if (confirm(__("Remove stored feed icon?"))) {
Notify.progress("Removing feed icon...", true);
const query = {op: "pref-feeds", method: "removeicon", feed_id: id};
xhrPost("backend.php", query, () => {
Notify.info("Feed icon removed.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = $$(".feed-editor-icon")[0];
if (icon)
icon.src = icon.src.replace(/\?[0-9]+$/, "?" + new Date().getTime());
});
}
return false;
},
uploadFeedIcon: function() {
const file = $("icon_file");
if (file.value.length == 0) {
alert(__("Please select an image file to upload."));
} else if (confirm(__("Upload new icon for this feed?"))) {
Notify.progress("Uploading, please wait...", true);
const xhr = new XMLHttpRequest();
xhr.open( 'POST', 'backend.php', true );
xhr.onload = function () {
switch (parseInt(this.responseText)) {
case 0:
{
Notify.info("Upload complete.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = $$(".feed-editor-icon")[0];
if (icon)
icon.src = icon.src.replace(/\?[0-9]+$/, "?" + new Date().getTime());
}
break;
case 1:
Notify.error("Upload failed: icon is too big.");
break;
case 2:
Notify.error("Upload failed.");
break;
}
};
xhr.send(new FormData($("feed_icon_upload_form")));
}
return false;
},
quickAddFeed: function() {
xhrPost("backend.php",
{op: "feeds", method: "quickAddFeed"},
(transport) => {
subscribeToFeed: function() {
xhr.json("backend.php",
{op: "feeds", method: "subscribeToFeed"},
(reply) => {
const dialog = new fox.SingleUseDialog({
id: "feedAddDlg",
title: __("Subscribe to Feed"),
content: transport.responseText,
content: `
<form onsubmit='return false'>
${App.FormFields.hidden_tag("op", "feeds")}
${App.FormFields.hidden_tag("method", "add")}
<div id='fadd_error_message' style='display : none' class='alert alert-danger'></div>
<div id='fadd_multiple_notify' style='display : none'>
<div class='alert alert-info'>
${__("Provided URL is a HTML page referencing multiple feeds, please select required feed from the dropdown menu below.")}
</div>
</div>
<section>
<fieldset>
<div style='float : right'><img style='display : none' id='feed_add_spinner' src='images/indicator_white.gif'></div>
<input style='font-size : 16px; width : 500px;'
placeHolder="${__("Feed or site URL")}"
dojoType='dijit.form.ValidationTextBox'
required='1' name='feed' id='feedDlg_feedUrl'>
</fieldset>
${App.getInitParam('enable_feed_cats') ?
`
<fieldset>
<label class='inline'>${__('Place in category:')}</label>
${reply.cat_select}
</fieldset>
` : ''}
</section>
<div id="feedDlg_feedsContainer" style="display : none">
<header>${__('Available feeds')}</header>
<section>
<fieldset>
<select id="feedDlg_feedContainerSelect"
dojoType="fox.form.Select" size="3">
<script type="dojo/method" event="onChange" args="value">
dijit.byId("feedDlg_feedUrl").attr("value", value);
</script>
</select>
</fieldset>
</section>
</div>
<div id='feedDlg_loginContainer' style='display : none'>
<section>
<fieldset>
<input dojoType="dijit.form.TextBox" name='login'"
placeHolder="${__("Login")}"
autocomplete="new-password"
style="width : 10em;">
<input
placeHolder="${__("Password")}"
dojoType="dijit.form.TextBox" type='password'
autocomplete="new-password"
style="width : 10em;" name='pass'">
</fieldset>
</section>
</div>
<section>
<label class='checkbox'>
<input type='checkbox' name='need_auth' dojoType='dijit.form.CheckBox' id='feedDlg_loginCheck'
onclick='App.displayIfChecked(this, "feedDlg_loginContainer")'>
${__('This feed requires authentication.')}
</label>
</section>
<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'
onclick='App.dialogOf(this).execute()'>
${__('Subscribe')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
</form>
`,
show_error: function (msg) {
const elem = $("fadd_error_message");
const elem = App.byId("fadd_error_message");
elem.innerHTML = msg;
if (!Element.visible(elem))
new Effect.Appear(elem);
Element.show(elem);
},
execute: function () {
if (this.validate()) {
@ -104,17 +114,12 @@ const CommonDialogs = {
Element.show("feed_add_spinner");
Element.hide("fadd_error_message");
xhrPost("backend.php", this.attr('value'), (transport) => {
xhr.json("backend.php", this.attr('value'), (reply) => {
try {
let reply;
try {
reply = JSON.parse(transport.responseText);
} catch (e) {
if (!reply) {
Element.hide("feed_add_spinner");
alert(__("Failed to parse output. This can indicate server timeout and/or network issues. Backend output was logged to browser console."));
console.log('quickAddFeed, backend returned:' + transport.responseText);
return;
}
@ -161,7 +166,7 @@ const CommonDialogs = {
}
}
Effect.Appear('feedDlg_feedsContainer', {duration: 0.5});
Element.show('feedDlg_feedsContainer');
}
break;
case 5:
@ -188,69 +193,103 @@ const CommonDialogs = {
});
},
showFeedsWithErrors: function() {
const dialog = new fox.SingleUseDialog({
id: "errorFeedsDlg",
title: __("Feeds with update errors"),
getSelectedFeeds: function () {
return Tables.getSelected("error-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
xhr.json("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (reply) => {
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
const dialog = new fox.SingleUseDialog({
id: "errorFeedsDlg",
title: __("Feeds with update errors"),
getSelectedFeeds: function () {
return Tables.getSelected("error-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
xhrPost("backend.php", query, () => {
Notify.close();
dialog.hide();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
});
}
xhr.post("backend.php", query, () => {
Notify.close();
dialog.hide();
} else {
alert(__("No feeds selected."));
}
},
content: __("Loading, please wait...")
});
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
});
}
xhrPost("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (transport) => {
dialog.attr('content', transport.responseText);
})
});
} else {
alert(__("No feeds selected."));
}
},
content: `
<div dojoType="fox.Toolbar">
<div dojoType="fox.form.DropDownButton">
<span>${__('Select')}</span>
<div dojoType="dijit.Menu" style="display: none">
<div onclick="Tables.select('error-feeds-list', true)"
dojoType="dijit.MenuItem">${__('All')}</div>
<div onclick="Tables.select('error-feeds-list', false)"
dojoType="dijit.MenuItem">${__('None')}</div>
</div>
</div>
</div>
<div class='panel panel-scrollable'>
<table width='100%' id='error-feeds-list'>
${reply.map((row) => `
<tr data-row-id='${row.id}'>
<td width='5%' align='center'>
<input onclick='Tables.onRowChecked(this)' dojoType="dijit.form.CheckBox"
type="checkbox">
</td>
<td>
<a href="#" title="${__("Click to edit feed")}" onclick="CommonDialogs.editFeed(${row.id})">
${App.escapeHtml(row.title)}
</a>
</td>
<td class='text-muted small' align='right' width='50%'>
${App.escapeHtml(row.last_error)}
</td>
</tr>
`).join("")}
</table>
</div>
<footer>
<button style='float : left' class='alt-danger' dojoType='dijit.form.Button' onclick='App.dialogOf(this).removeSelected()'>
${__('Unsubscribe from selected feeds')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${__('Close this window')}
</button>
</footer>
`
});
dialog.show();
dialog.show();
})
},
addLabel: function(select, callback) {
addLabel: function() {
const caption = prompt(__("Please enter label caption:"), "");
if (caption != undefined && caption.trim().length > 0) {
const query = {op: "pref-labels", method: "add", caption: caption.trim()};
if (select)
Object.extend(query, {output: "select"});
Notify.progress("Loading, please wait...", true);
xhrPost("backend.php", query, (transport) => {
if (callback) {
callback(transport);
} else if (App.isPrefs()) {
xhr.post("backend.php", query, () => {
if (dijit.byId("labelTree")) {
dijit.byId("labelTree").reload();
} else {
Feeds.reload();
@ -267,13 +306,13 @@ const CommonDialogs = {
const query = {op: "pref-feeds", quiet: 1, method: "remove", ids: feed_id};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
if (App.isPrefs()) {
dijit.byId("feedTree").reload();
} else {
if (feed_id == Feeds.getActive())
setTimeout(() => {
Feeds.open({feed: -5})
Feeds.openDefaultFeed();
},
100);
@ -284,28 +323,109 @@ const CommonDialogs = {
return false;
},
editFeed: function (feed) {
if (feed <= 0)
editFeed: function (feed_id) {
if (feed_id <= 0)
return alert(__("You can't edit this kind of feed."));
const query = {op: "pref-feeds", method: "editfeed", id: feed};
const query = {op: "pref-feeds", method: "editfeed", id: feed_id};
console.log("editFeed", query);
const dialog = new fox.SingleUseDialog({
id: "feedEditDlg",
title: __("Edit Feed"),
unsubscribeFeed: function(feed_id, title) {
if (confirm(__("Unsubscribe from %s?").replace("%s", title))) {
feed_title: "",
unsubscribe: function() {
if (confirm(__("Unsubscribe from %s?").replace("%s", this.feed_title))) {
dialog.hide();
CommonDialogs.unsubscribeFeed(feed_id);
}
},
uploadIcon: function(input) {
if (input.files.length != 0) {
const icon_file = input.files[0];
if (icon_file.type.indexOf("image/") == -1) {
alert(__("Please select an image file."));
return;
}
const fd = new FormData();
fd.append('icon_file', icon_file)
fd.append('feed_id', feed_id);
fd.append('op', 'pref-feeds');
fd.append('method', 'uploadIcon');
fd.append('csrf_token', App.getInitParam("csrf_token"));
const xhr = new XMLHttpRequest();
xhr.open( 'POST', 'backend.php', true );
xhr.onload = function () {
console.log(this.responseText);
// TODO: make a notice box within panel content
switch (parseInt(this.responseText)) {
case 1:
Notify.error("Upload failed: icon is too big.");
break;
case 2:
Notify.error("Upload failed.");
break;
default:
{
Notify.info("Upload complete.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = dialog.domNode.querySelector(".feedIcon");
if (icon) {
icon.src = this.responseText;
icon.show();
}
input.value = "";
}
}
};
xhr.send(fd);
}
},
removeIcon: function(id) {
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, () => {
Notify.info("Feed icon removed.");
if (App.isPrefs())
dijit.byId("feedTree").reload();
else
Feeds.reload();
const icon = dialog.domNode.querySelector(".feedIcon");
if (icon) {
icon.src = "";
icon.hide();
}
});
}
return false;
},
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhrPost("backend.php", dialog.attr('value'), () => {
xhr.post("backend.php", dialog.attr('value'), () => {
dialog.hide();
Notify.close();
@ -315,7 +435,9 @@ const CommonDialogs = {
Feeds.reload();
});
return true;
}
return false;
},
content: __("Loading, please wait...")
});
@ -323,102 +445,195 @@ const CommonDialogs = {
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhrPost("backend.php", {op: "pref-feeds", method: "editfeed", id: feed}, (transport) => {
dialog.attr('content', transport.responseText);
xhr.json("backend.php", {op: "pref-feeds", method: "editfeed", id: feed_id}, (reply) => {
const feed = reply.feed;
// for unsub prompt
dialog.feed_title = feed.title;
// options tab
const options = {
include_in_digest: [ feed.include_in_digest, __('Include in e-mail digest') ],
always_display_enclosures: [ feed.always_display_enclosures, __('Always display image attachments') ],
hide_images: [ feed.hide_images, __('Do not embed media') ],
cache_images: [ feed.cache_images, __('Cache media') ],
mark_unread_on_update: [ feed.mark_unread_on_update, __('Mark updated articles as unread') ]
};
dialog.attr('content',
`
<form onsubmit="return false">
<div dojoType="dijit.layout.TabContainer" style="height : 450px">
<div dojoType="dijit.layout.ContentPane" title="${__('General')}">
${App.FormFields.hidden_tag("id", feed_id)}
${App.FormFields.hidden_tag("op", "pref-feeds")}
${App.FormFields.hidden_tag("method", "editSave")}
<section>
<fieldset>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Feed Title")}"
style='font-size : 16px; width: 500px' name='title' value="${App.escapeHtml(feed.title)}">
</fieldset>
<fieldset>
<label>${__('URL:')}</label>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Feed URL")}"
regExp='^(http|https)://.*' style='width : 300px'
name='feed_url' value="${App.escapeHtml(feed.feed_url)}">
${feed.last_error ?
`<i class="material-icons"
title="${App.escapeHtml(feed.last_error)}">error</i>
` : ""}
</fieldset>
${reply.cats.enabled ?
`
<fieldset>
<label>${__('Place in category:')}</label>
${reply.cats.select}
</fieldset>
` : ""}
<fieldset>
<label>${__('Site URL:')}</label>
<input dojoType='dijit.form.ValidationTextBox' required='1'
placeHolder="${__("Site URL")}"
regExp='^(http|https)://.*' style='width : 300px'
name='site_url' value="${App.escapeHtml(feed.site_url)}">
</fieldset>
${reply.lang.enabled ?
`
<fieldset>
<label>${__('Language:')}</label>
${App.FormFields.select_tag("feed_language",
feed.feed_language ? feed.feed_language : reply.lang.default,
reply.lang.all)}
</fieldset>
` : ""}
<hr/>
<fieldset>
<label>${__("Update interval:")}</label>
${App.FormFields.select_hash("update_interval", feed.update_interval, reply.intervals.update)}
</fieldset>
<fieldset>
<label>${__('Article purging:')}</label>
${App.FormFields.select_hash("purge_interval",
feed.purge_interval,
reply.intervals.purge,
reply.force_purge ? {disabled: 1} : {})}
</fieldset>
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Authentication')}">
<section>
<fieldset>
<label>${__("Login:")}</label>
<input dojoType='dijit.form.TextBox'
autocomplete='new-password'
name='auth_login' value="${App.escapeHtml(feed.auth_login)}">
</fieldset>
<fieldset>
<label>${__("Password:")}</label>
<input dojoType='dijit.form.TextBox' type='password' name='auth_pass'
autocomplete='new-password'
value="${App.escapeHtml(feed.auth_pass)}">
</fieldset>
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Options')}">
<section class="narrow">
${Object.keys(options).map((name) =>
`
<fieldset class='narrow'>
<label class="checkbox">
${App.FormFields.checkbox_tag(name, options[name][0])}
${options[name][1]}
</label>
</fieldset>
`).join("")}
</section>
</div>
<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...")}
<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+")"})}
</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.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
`);
})
});
dialog.show();
},
genUrlChangeKey: function(feed, is_cat) {
if (confirm(__("Generate new syndication address for this feed?"))) {
Notify.progress("Trying to change address...", true);
const query = {op: "pref-feeds", method: "regenFeedKey", id: feed, is_cat: is_cat};
xhrJson("backend.php", query, (reply) => {
const new_link = reply.link;
const e = $('gen_feed_url');
if (new_link) {
e.innerHTML = e.innerHTML.replace(/&amp;key=.*$/,
"&amp;key=" + new_link);
e.href = e.href.replace(/&key=.*$/,
"&key=" + new_link);
new Effect.Highlight(e);
Notify.close();
} else {
Notify.error("Could not change feed URL.");
}
});
}
return false;
},
publishedOPML: function() {
generatedFeed: function(feed, is_cat, search = "") {
Notify.progress("Loading, please wait...", true);
xhrJson("backend.php", {op: "pref-feeds", method: "getOPMLKey"}, (reply) => {
xhr.json("backend.php", {op: "pref-feeds", method: "getsharedurl", id: feed, is_cat: is_cat, search: search}, (reply) => {
try {
const dialog = new fox.SingleUseDialog({
title: __("Public OPML URL"),
content: `
<header>${__("Your Public OPML URL is:")}</header>
<section>
<div class='panel text-center'>
<a id='pub_opml_url' href="${App.escapeHtml(reply.link)}" target='_blank'>${reply.link}</a>
</div>
</section>
<footer class='text-center'>
<button dojoType='dijit.form.Button' onclick="return Helpers.OPML.changeKey()">
${__('Generate new URL')}
</button>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>
${__('Close this window')}
</button>
</footer>
`
});
title: __("Show as feed"),
regenFeedKey: function(feed, is_cat) {
if (confirm(__("Generate new syndication address for this feed?"))) {
dialog.show();
Notify.progress("Trying to change address...", true);
Notify.close();
const query = {op: "pref-feeds", method: "regenFeedKey", id: feed, is_cat: is_cat};
} catch (e) {
App.Error.report(e);
}
});
},
generatedFeed: function(feed, is_cat, rss_url, feed_title) {
xhr.json("backend.php", query, (reply) => {
const new_link = reply.link;
const target = this.domNode.querySelector(".generated_url");
Notify.progress("Loading, please wait...", true);
if (new_link && target) {
target.innerHTML = target.innerHTML.replace(/&amp;key=.*$/,
"&amp;key=" + new_link);
xhrJson("backend.php", {op: "pref-feeds", method: "getFeedKey", id: feed, is_cat: is_cat}, (reply) => {
try {
if (!feed_title && typeof Feeds != "undefined")
feed_title = Feeds.getName(feed, is_cat);
target.href = target.href.replace(/&key=.*$/,
"&key=" + new_link);
const secret_url = rss_url + "&key=" + encodeURIComponent(reply.link);
Notify.close();
const dialog = new fox.SingleUseDialog({
title: __("Show as feed"),
} else {
Notify.error("Could not change feed URL.");
}
});
}
return false;
},
content: `
<header>${__("%s can be accessed via the following secret URL:").replace("%s", feed_title)}</header>
<header>${__("%s can be accessed via the following secret URL:").replace("%s", App.escapeHtml(reply.title))}</header>
<section>
<div class='panel text-center'>
<a id='gen_feed_url' href="${App.escapeHtml(secret_url)}" target='_blank'>${secret_url}</a>
<a class='generated_url' href="${App.escapeHtml(reply.link)}" target='_blank'>${App.escapeHtml(reply.link)}</a>
</div>
</section>
<footer>
<button dojoType='dijit.form.Button' style='float : left' class='alt-info'
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 CommonDialogs.genUrlChangeKey('${feed}', '${is_cat}')">
<button dojoType='dijit.form.Button' onclick="return App.dialogOf(this).regenFeedKey('${feed}', '${is_cat}')">
${__('Generate new URL')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>

@ -2,365 +2,560 @@
/* eslint-disable no-new */
/* global __, App, Article, Lists, Effect, fox */
/* global xhrPost, dojo, dijit, Notify, $$, Feeds */
/* global __, App, Article, Lists, fox */
/* global xhr, dojo, dijit, Notify, Feeds */
/* exported Filters */
const Filters = {
filterDlgCheckAction: function(sender) {
const action = sender.value;
const action_param = $("filterDlg_paramBox");
if (!action_param) {
console.log("filterDlgCheckAction: can't find action param box!");
return;
}
// if selected action supports parameters, enable params field
if (action == 4 || action == 6 || action == 7 || action == 9) {
new Effect.Appear(action_param, {duration: 0.5});
Element.hide(dijit.byId("filterDlg_actionParam").domNode);
Element.hide(dijit.byId("filterDlg_actionParamLabel").domNode);
Element.hide(dijit.byId("filterDlg_actionParamPlugin").domNode);
if (action == 7) {
Element.show(dijit.byId("filterDlg_actionParamLabel").domNode);
} else if (action == 9) {
Element.show(dijit.byId("filterDlg_actionParamPlugin").domNode);
} else {
Element.show(dijit.byId("filterDlg_actionParam").domNode);
}
} else {
Element.hide(action_param);
}
},
createNewRuleElement: function(parentNode, replaceNode) {
const rule = dojo.formToJson("filter_new_rule_form");
xhrPost("backend.php", {op: "pref-filters", method: "printrulename", rule: rule}, (transport) => {
try {
const li = document.createElement('li');
li.innerHTML = `<input dojoType='dijit.form.CheckBox' type='checkbox' onclick='Lists.onRowChecked(this)'>
<span onclick='App.dialogOf(this).editRule(this)'>${transport.responseText}</span>
${App.FormFields.hidden("rule[]", rule)}`;
edit: function(filter_id = null) { // if no id, new filter dialog
dojo.parser.parse(li);
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
createNewActionElement: function(parentNode, replaceNode) {
const form = document.forms["filter_new_action_form"];
const dialog = new fox.SingleUseDialog({
id: "filterEditDlg",
title: filter_id ? __("Edit Filter") : __("Create Filter"),
ACTION_TAG: 4,
ACTION_SCORE: 6,
ACTION_LABEL: 7,
ACTION_PLUGIN: 9,
PARAM_ACTIONS: [4, 6, 7, 9],
filter_info: {},
test: function() {
const test_dialog = new fox.SingleUseDialog({
title: "Test Filter",
results: 0,
limit: 100,
max_offset: 10000,
getTestResults: function (params, offset) {
params.method = 'testFilterDo';
params.offset = offset;
params.limit = test_dialog.limit;
console.log("getTestResults:" + offset);
xhr.json("backend.php", params, (result) => {
try {
if (result && test_dialog && test_dialog.open) {
test_dialog.results += result.length;
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);
console.log(offset + " " + test_dialog.max_offset);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("table", { innerHTML: result[i]});
App.byId("prefFilterTestResultList").innerHTML += tmp.innerHTML;
}
if (test_dialog.results < 30 && offset < test_dialog.max_offset) {
// get the next batch
window.setTimeout(function () {
test_dialog.getTestResults(params, offset + test_dialog.limit);
}, 0);
} else {
// all done
Element.hide("prefFilterLoadingIndicator");
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:";
} else {
App.byId("prefFilterProgressMsg").innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
}
} else if (!result) {
console.log("getTestResults: can't parse results object");
Element.hide("prefFilterLoadingIndicator");
Notify.error("Error while trying to get filter test results.");
} else {
console.log("getTestResults: dialog closed, bailing out.");
}
} catch (e) {
App.Error.report(e);
}
});
},
content: `
<div>
<img id='prefFilterLoadingIndicator' src='images/indicator_tiny.gif'>&nbsp;
<span id='prefFilterProgressMsg'>Looking for articles...</span>
</div>
<ul class='panel panel-scrollable list list-unstyled' id='prefFilterTestResultList'></ul>
<footer class='text-center'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>${__('Close this window')}</button>
</footer>
`
});
if (form.action_id.value == 7) {
form.action_param.value = form.action_param_label.value;
} else if (form.action_id.value == 9) {
form.action_param.value = form.action_param_plugin.value;
}
const tmph = dojo.connect(test_dialog, "onShow", null, function (/* e */) {
dojo.disconnect(tmph);
const action = dojo.formToJson(form);
test_dialog.getTestResults(dialog.attr('value'), 0);
});
xhrPost("backend.php", { op: "pref-filters", method: "printactionname", action: action }, (transport) => {
try {
const li = document.createElement('li');
test_dialog.show();
},
insertRule: function(parentNode, replaceNode) {
const rule = dojo.formToJson("filter_new_rule_form");
li.innerHTML = `<input dojoType='dijit.form.CheckBox' type='checkbox' onclick='Lists.onRowChecked(this)'>
<span onclick='App.dialogOf(this).editAction(this)'>${transport.responseText}</span>
${App.FormFields.hidden("action[]", action)}`;
xhr.post("backend.php", {op: "pref-filters", method: "printrulename", rule: rule}, (reply) => {
try {
const li = document.createElement('li');
li.addClassName("rule");
dojo.parser.parse(li);
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>`;
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
parentNode.appendChild(li);
}
dojo.parser.parse(li);
} catch (e) {
App.Error.report(e);
}
});
},
addFilterRule: function(replaceNode, ruleStr) {
const dialog = new fox.SingleUseDialog({
id: "filterNewRuleDlg",
title: ruleStr ? __("Edit rule") : __("Add rule"),
execute: function () {
if (this.validate()) {
Filters.createNewRuleElement($("filterDlg_Matches"), replaceNode);
this.hide();
}
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
content: __('Loading, please wait...'),
});
const tmph = dojo.connect(dialog, "onShow", null, function (/* e */) {
dojo.disconnect(tmph);
xhrPost("backend.php", {op: 'pref-filters', method: 'newrule', rule: ruleStr}, (transport) => {
dialog.attr('content', transport.responseText);
});
});
insertAction: function(parentNode, replaceNode) {
const form = document.forms["filter_new_action_form"];
dialog.show();
},
addFilterAction: function(replaceNode, actionStr) {
const dialog = new fox.SingleUseDialog({
title: actionStr ? __("Edit action") : __("Add action"),
execute: function () {
if (this.validate()) {
Filters.createNewActionElement($("filterDlg_Actions"), replaceNode);
this.hide();
if (form.action_id.value == 7) {
form.action_param.value = form.action_param_label.value;
} else if (form.action_id.value == 9) {
form.action_param.value = form.action_param_plugin.value;
}
}
});
const tmph = dojo.connect(dialog, "onShow", null, function (/* e */) {
dojo.disconnect(tmph);
xhrPost("backend.php", {op: 'pref-filters', method: 'newaction', action: actionStr}, (transport) => {
dialog.attr('content', transport.responseText);
});
});
const action = dojo.formToJson(form);
dialog.show();
},
test: function(params) {
const dialog = new fox.SingleUseDialog({
title: "Test Filter",
results: 0,
limit: 100,
max_offset: 10000,
getTestResults: function (params, offset) {
params.method = 'testFilterDo';
params.offset = offset;
params.limit = dialog.limit;
console.log("getTestResults:" + offset);
xhrPost("backend.php", params, (transport) => {
xhr.post("backend.php", { op: "pref-filters", method: "printactionname", action: action }, (reply) => {
try {
const result = JSON.parse(transport.responseText);
if (result && dialog && dialog.open) {
dialog.results += result.length;
console.log("got results:" + result.length);
const li = document.createElement('li');
li.addClassName("action");
$("prefFilterProgressMsg").innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", dialog.results)
.replace("%d", offset);
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>`;
console.log(offset + " " + dialog.max_offset);
dojo.parser.parse(li);
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("table", { innerHTML: result[i]});
$("prefFilterTestResultList").innerHTML += tmp.innerHTML;
}
if (dialog.results < 30 && offset < dialog.max_offset) {
// get the next batch
window.setTimeout(function () {
dialog.getTestResults(params, offset + dialog.limit);
}, 0);
} else {
// all done
Element.hide("prefFilterLoadingIndicator");
if (dialog.results == 0) {
$("prefFilterTestResultList").innerHTML = `<tr><td align='center'>
${__('No recent articles matching this filter have been found.')}</td></tr>`;
$("prefFilterProgressMsg").innerHTML = "Articles matching this filter:";
} else {
$("prefFilterProgressMsg").innerHTML = __("Found %d articles matching this filter:")
.replace("%d", dialog.results);
}
}
} else if (!result) {
console.log("getTestResults: can't parse results object");
Element.hide("prefFilterLoadingIndicator");
Notify.error("Error while trying to get filter test results.");
if (replaceNode) {
parentNode.replaceChild(li, replaceNode);
} else {
console.log("getTestResults: dialog closed, bailing out.");
parentNode.appendChild(li);
}
} catch (e) {
App.Error.report(e);
}
});
},
content: `
<div>
<img id='prefFilterLoadingIndicator' src='images/indicator_tiny.gif'>&nbsp;
<span id='prefFilterProgressMsg'>Looking for articles...</span>
</div>
<ul class='panel panel-scrollable list list-unstyled' id='prefFilterTestResultList'></ul>
<footer class='text-center'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>${__('Close this window')}</button>
</footer>
`
});
dojo.connect(dialog, "onShow", null, function (/* e */) {
dialog.getTestResults(params, 0);
});
dialog.show();
},
edit: function(id) { // if no id, new filter dialog
let query;
if (!App.isPrefs()) {
query = {
op: "pref-filters", method: "edit",
feed: Feeds.getActive(), is_cat: Feeds.activeIsCat()
};
} else {
query = {op: "pref-filters", method: "edit", id: id};
}
console.log('Filters.edit', query);
xhrPost("backend.php", query, function (transport) {
try {
const dialog = new fox.SingleUseDialog({
id: "filterEditDlg",
title: __("Create Filter"),
test: function () {
Filters.test(this.attr('value'));
},
selectRules: function (select) {
Lists.select("filterDlg_Matches", select);
},
selectActions: function (select) {
Lists.select("filterDlg_Actions", select);
},
editRule: function (e) {
const li = e.closest('li');
const rule = li.querySelector('input[name="rule[]"]').value
Filters.addFilterRule(li, rule);
editRule: function(replaceNode, ruleStr = null) {
const edit_rule_dialog = new fox.SingleUseDialog({
id: "filterNewRuleDlg",
title: ruleStr ? __("Edit rule") : __("Add rule"),
execute: function () {
if (this.validate()) {
dialog.insertRule(App.byId("filterDlg_Matches"), replaceNode);
this.hide();
}
},
editAction: function (e) {
const li = e.closest('li');
const action = li.querySelector('input[name="action[]"]').value
content: __('Loading, please wait...'),
});
Filters.addFilterAction(li, action);
},
removeFilter: function () {
const msg = __("Remove filter?");
const tmph = dojo.connect(edit_rule_dialog, "onShow", null, function () {
dojo.disconnect(tmph);
if (confirm(msg)) {
this.hide();
let rule;
Notify.progress("Removing filter...");
if (ruleStr) {
rule = JSON.parse(ruleStr);
} else {
rule = {
reg_exp: "",
filter_type: 1,
feed_id: ["0"],
inverse: false,
};
}
const query = {op: "pref-filters", method: "remove", ids: this.attr('value').id};
console.log(rule, dialog.filter_info);
xhr.json("backend.php", {op: "pref-filters", method: "editrule", ids: rule.feed_id.join(",")}, function (editrule) {
edit_rule_dialog.attr('content',
`
<form name="filter_new_rule_form" id="filter_new_rule_form" onsubmit="return false">
<section>
<textarea dojoType="fox.form.ValidationTextArea"
required="true" id="filterDlg_regExp" ValidRegExp="true"
rows="4" style="font-size : 14px; width : 530px; word-break: break-all"
name="reg_exp">${rule.reg_exp}</textarea>
<div dojoType="dijit.Tooltip" id="filterDlg_regExp_tip" connectId="filterDlg_regExp" position="below"></div>
<fieldset>
<label class="checkbox">
${App.FormFields.checkbox_tag("inverse", rule.inverse)}
${__("Inverse regular expression matching")}
</label>
</fieldset>
<fieldset>
<label style="display : inline">${__("on")}</label>
${App.FormFields.select_hash("filter_type", rule.filter_type, dialog.filter_info.filter_types)}
<label style="padding-left : 10px; display : inline">${__("in")}</label>
</fieldset>
<fieldset>
<span id="filterDlg_feeds">
${editrule.multiselect}
</span>
</fieldset>
</section>
<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.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
`);
});
xhrPost("backend.php", query, () => {
const tree = dijit.byId("filterTree");
});
if (tree) tree.reload();
});
}
},
addAction: function () {
Filters.addFilterAction();
},
addRule: function () {
Filters.addFilterRule();
},
deleteAction: function () {
$$("#filterDlg_Actions li[class*=Selected]").each(function (e) {
e.parentNode.removeChild(e)
});
edit_rule_dialog.show();
},
editAction: function(replaceNode, actionStr) {
const edit_action_dialog = new fox.SingleUseDialog({
title: actionStr ? __("Edit action") : __("Add action"),
select_labels: function(name, value, labels, attributes = {}, id = "") {
const values = Object.values(labels).map((label) => label.caption);
return App.FormFields.select_tag(name, value, values, attributes, id);
},
deleteRule: function () {
$$("#filterDlg_Matches li[class*=Selected]").each(function (e) {
e.parentNode.removeChild(e)
});
toggleParam: function(sender) {
const action = parseInt(sender.value);
dijit.byId("filterDlg_actionParam").domNode.hide();
dijit.byId("filterDlg_actionParamLabel").domNode.hide();
dijit.byId("filterDlg_actionParamPlugin").domNode.hide();
// if selected action supports parameters, enable params field
if (action == dialog.ACTION_LABEL) {
dijit.byId("filterDlg_actionParamLabel").domNode.show();
} else if (action == dialog.ACTION_PLUGIN) {
dijit.byId("filterDlg_actionParamPlugin").domNode.show();
} else if (dialog.PARAM_ACTIONS.indexOf(action) != -1) {
dijit.byId("filterDlg_actionParam").domNode.show();
}
},
execute: function () {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhrPost("backend.php", this.attr('value'), () => {
dialog.hide();
const tree = dijit.byId("filterTree");
if (tree) tree.reload();
});
dialog.insertAction(App.byId("filterDlg_Actions"), replaceNode);
this.hide();
}
},
content: transport.responseText
content: __("Loading, please wait...")
});
if (!App.isPrefs()) {
/* global getSelectionText */
const selectedText = getSelectionText();
const tmph = dojo.connect(edit_action_dialog, "onShow", null, function () {
dojo.disconnect(tmph);
const lh = dojo.connect(dialog, "onShow", function () {
dojo.disconnect(lh);
let action;
if (selectedText != "") {
if (actionStr) {
action = JSON.parse(actionStr);
} else {
action = {
action_id: 2,
action_param: ""
};
}
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
console.log(action);
edit_action_dialog.attr('content',
`
<form name="filter_new_action_form" id="filter_new_action_form" onsubmit="return false;">
<section>
${App.FormFields.select_hash("action_id", -1,
dialog.filter_info.action_types,
{onchange: "App.dialogOf(this).toggleParam(this)"},
"filterDlg_actionSelect")}
<input dojoType="dijit.form.TextBox"
id="filterDlg_actionParam" style="$param_hidden"
name="action_param" value="${App.escapeHtml(action.action_param)}">
${edit_action_dialog.select_labels("action_param_label", action.action_param,
dialog.filter_info.labels,
{},
"filterDlg_actionParamLabel")}
${App.FormFields.select_hash("action_param_plugin", action.action_param,
dialog.filter_info.plugin_actions,
{},
"filterDlg_actionParamPlugin")}
</section>
<footer>
${App.FormFields.submit_tag(__("Save action"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
</form>
`);
dijit.byId("filterDlg_actionSelect").attr('value', action.action_id);
/*xhr.post("backend.php", {op: 'pref-filters', method: 'newaction', action: actionStr}, (reply) => {
edit_action_dialog.attr('content', reply);
setTimeout(() => {
edit_action_dialog.hideOrShowActionParam(dijit.byId("filterDlg_actionSelect").attr('value'));
}, 250);
});*/
});
const rule = {reg_exp: selectedText, feed_id: [feed_id], filter_type: 1};
edit_action_dialog.show();
},
selectRules: function (select) {
Lists.select("filterDlg_Matches", select);
},
selectActions: function (select) {
Lists.select("filterDlg_Actions", select);
},
onRuleClicked: function (elem) {
Filters.addFilterRule(null, dojo.toJson(rule));
const li = elem.closest('li');
const rule = li.querySelector('input[name="rule[]"]').value;
} else {
this.editRule(li, rule);
},
onActionClicked: function (elem) {
const query = {op: "rpc", method: "getlinktitlebyid", id: Article.getActive()};
const li = elem.closest('li');
const action = li.querySelector('input[name="action[]"]').value;
xhrPost("backend.php", query, (transport) => {
const reply = JSON.parse(transport.responseText);
this.editAction(li, action);
},
removeFilter: function () {
const msg = __("Remove filter?");
let title = false;
if (confirm(msg)) {
this.hide();
Notify.progress("Removing filter...");
if (reply && reply.title) title = reply.title;
const query = {op: "pref-filters", method: "remove", ids: this.attr('value').id};
if (title || Feeds.getActive() || Feeds.activeIsCat()) {
xhr.post("backend.php", query, () => {
const tree = dijit.byId("filterTree");
console.log(title + " " + Feeds.getActive());
if (tree) tree.reload();
});
}
},
addAction: function () {
this.editAction();
},
addRule: function () {
this.editRule();
},
deleteAction: function () {
App.findAll("#filterDlg_Actions li[class*=Selected]").forEach(function (e) {
e.parentNode.removeChild(e)
});
},
deleteRule: function () {
App.findAll("#filterDlg_Matches li[class*=Selected]").forEach(function (e) {
e.parentNode.removeChild(e)
});
},
execute: function () {
if (this.validate()) {
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
Notify.progress("Saving data...", true);
const rule = {reg_exp: title, feed_id: [feed_id], filter_type: 1};
xhr.post("backend.php", this.attr('value'), () => {
dialog.hide();
Filters.addFilterRule(null, dojo.toJson(rule));
}
});
}
const tree = dijit.byId("filterTree");
if (tree) tree.reload();
});
}
dialog.show();
},
content: __("Loading, please wait...")
});
} catch (e) {
App.Error.report(e);
}
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhr.json("backend.php", {op: "pref-filters", method: "edit", id: filter_id}, function (filter) {
dialog.filter_info = filter;
const options = {
enabled: [ filter.enabled, __('Enabled') ],
match_any_rule: [ filter.match_any_rule, __('Match any rule') ],
inverse: [ filter.inverse, __('Inverse matching') ],
};
dialog.attr('content',
`
<form onsubmit='return false'>
${App.FormFields.hidden_tag("op", "pref-filters")}
${App.FormFields.hidden_tag("id", filter_id)}
${App.FormFields.hidden_tag("method", filter_id ? "editSave" : "add")}
${App.FormFields.hidden_tag("csrf_token", App.getInitParam('csrf_token'))}
<section class="horizontal">
<input required="true" dojoType="dijit.form.ValidationTextBox" style="width : 100%"
placeholder="${__("Title")}" name="title" value="${App.escapeHtml(filter.title)}">
</section>
<div dojoType="dijit.layout.TabContainer" style="height : 300px">
<div dojoType="dijit.layout.ContentPane" title="${__('Match')}">
<div style="padding : 0" dojoType="dijit.layout.BorderContainer" gutters="false">
<div dojoType="fox.Toolbar" region="top">
<div dojoType="fox.form.DropDownButton">
<span>${__("Select")}</span>
<div dojoType="dijit.Menu" style="display: none;">
<!-- can"t use App.dialogOf() here because DropDownButton is not a child of the Dialog -->
<div onclick="dijit.byId('filterEditDlg').selectRules(true)"
dojoType="dijit.MenuItem">${__("All")}</div>
<div onclick="dijit.byId('filterEditDlg').selectRules(false)"
dojoType="dijit.MenuItem">${__("None")}</div>
</div>
</div>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).addRule()">
${__("Add")}
</button>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).deleteRule()">
${__("Delete")}
</button>
</div>
<div dojoType="dijit.layout.ContentPane" region="center">
<ul id="filterDlg_Matches">
${filter.rules.map((rule) => `
<li class='rule'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class='name' onclick='App.dialogOf(this).onRuleClicked(this)'>${rule.name}</span>
<span class='payload'>${App.FormFields.hidden_tag("rule[]", JSON.stringify(rule))}</span>
</li>
`).join("")}
</ul>
</div>
</div>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('Apply actions')}">
<div style="padding : 0" dojoType="dijit.layout.BorderContainer" gutters="false">
<div dojoType="fox.Toolbar" region="top">
<div dojoType="fox.form.DropDownButton">
<span>${__("Select")}</span>
<div dojoType="dijit.Menu" style="display: none">
<div onclick="dijit.byId('filterEditDlg').selectActions(true)"
dojoType="dijit.MenuItem">${__("All")}</div>
<div onclick="dijit.byId('filterEditDlg').selectActions(false)"
dojoType="dijit.MenuItem">${__("None")}</div>
</div>
</div>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).addAction()">
${__("Add")}
</button>
<button dojoType="dijit.form.Button" onclick="App.dialogOf(this).deleteAction()">
${__("Delete")}
</button>
</div>
<div dojoType="dijit.layout.ContentPane" region="center">
<ul id="filterDlg_Actions">
${filter.actions.map((action) => `
<li class='rule'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})}
<span class='name' onclick='App.dialogOf(this).onActionClicked(this)'>${App.escapeHtml(action.name)}</span>
<span class='payload'>${App.FormFields.hidden_tag("action[]", JSON.stringify(action))}</span>
</li>
`).join("")}
</ul>
</div>
</div>
</div>
</div>
<section class="horizontal">
${Object.keys(options).map((name) =>
`
<fieldset class='narrow'>
<label class="checkbox">
${App.FormFields.checkbox_tag(name, options[name][0])}
${options[name][1]}
</label>
</fieldset>
`).join("")}
</section>
<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.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.cancel_dialog_tag(__("Cancel"))}
`}
</footer>
</form>
`);
if (!App.isPrefs()) {
const selectedText = App.getSelectedText();
if (selectedText != "") {
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
const rule = {reg_exp: selectedText, feed_id: [feed_id], filter_type: 1};
dialog.editRule(null, dojo.toJson(rule));
} else {
const query = {op: "article", method: "getmetadatabyid", id: Article.getActive()};
xhr.json("backend.php", query, (reply) => {
let title;
if (reply && reply.title) title = reply.title;
if (title || Feeds.getActive() || Feeds.activeIsCat()) {
console.log(title + " " + Feeds.getActive());
const feed_id = Feeds.activeIsCat() ? 'CAT:' + parseInt(Feeds.getActive()) :
Feeds.getActive();
const rule = {reg_exp: title, feed_id: [feed_id], filter_type: 1};
dialog.editRule(null, dojo.toJson(rule));
}
});
}
}
});
});
dialog.show();
},
};

@ -102,7 +102,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
label: __("Debug feed"),
onClick: function() {
/* global __csrf_token */
App.postOpenWindow("backend.php", {op: "feeds", method: "update_debugger",
App.postOpenWindow("backend.php", {op: "feeds", method: "updatedebugger",
feed_id: this.getParent().row_id, csrf_token: __csrf_token});
}}));
}
@ -286,7 +286,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
// focus headlines to route key events there
setTimeout(() => {
$("headlines-frame").focus();
App.byId("headlines-frame").focus();
if (treeNode) {
const node = treeNode.rowNode;
@ -295,7 +295,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (node && tree) {
// scroll tree to selection if needed
if (node.offsetTop < tree.scrollTop || node.offsetTop > tree.scrollTop + tree.clientHeight) {
$("feedTree").scrollTop = node.offsetTop;
App.byId("feedTree").scrollTop = node.offsetTop;
}
}
}

@ -1,8 +1,9 @@
'use strict'
/* global __, App, Headlines, xhrPost, dojo, dijit, Form, fox, PluginHost, Notify, $$, fox */
/* global __, App, Headlines, xhr, dojo, dijit, fox, PluginHost, Notify, fox */
const Feeds = {
_default_feed_id: -3,
counters_last_request: 0,
_active_feed_id: undefined,
_active_feed_is_cat: false,
@ -12,6 +13,19 @@ const Feeds = {
_search_query: false,
last_search_query: [],
_viewfeed_wait_timeout: false,
_feeds_holder_observer: new IntersectionObserver(
(entries/*, observer*/) => {
entries.forEach((entry) => {
//console.log('feeds',entry.target, entry.intersectionRatio);
if (entry.intersectionRatio == 0)
Feeds.onHide(entry);
else
Feeds.onShow(entry);
});
},
{threshold: [0, 1], root: document.querySelector("body")}
),
_counters_prev: [],
// NOTE: this implementation is incomplete
// for general objects but good enough for counters
@ -109,6 +123,9 @@ const Feeds = {
}
return false; // block unneeded form submits
},
openDefaultFeed: function() {
this.open({feed: this._default_feed_id});
},
openNextUnread: function() {
const is_cat = this.activeIsCat();
const nuf = this.getNextUnread(this.getActive(), is_cat);
@ -116,23 +133,20 @@ const Feeds = {
},
toggle: function() {
Element.toggle("feeds-holder");
const splitter = $("feeds-holder_splitter");
Element.visible("feeds-holder") ? splitter.show() : splitter.hide();
dijit.byId("main").resize();
Headlines.updateCurrentUnread();
},
cancelSearch: function() {
this._search_query = "";
this.reloadCurrent();
},
requestCounters: function() {
xhrPost("backend.php", {op: "rpc", method: "getAllCounters", seq: App.next_seq()}, (transport) => {
App.handleRpcJson(transport);
});
// null = get all data, [] would give empty response for specific type
requestCounters: function(feed_ids = null, label_ids = null) {
xhr.json("backend.php", {op: "rpc",
method: "getAllCounters",
"feed_ids[]": feed_ids,
"feed_id_count": feed_ids ? feed_ids.length : -1,
"label_ids[]": label_ids,
"label_id_count": label_ids ? label_ids.length : -1,
seq: App.next_seq()});
},
reload: function() {
try {
@ -180,7 +194,7 @@ const Feeds = {
dojo.disconnect(tmph);
});
$("feeds-holder").appendChild(tree.domNode);
App.byId("feeds-holder").appendChild(tree.domNode);
const tmph2 = dojo.connect(tree, 'onLoad', function () {
dojo.disconnect(tmph2);
@ -199,9 +213,23 @@ const Feeds = {
App.Error.report(e);
}
},
onHide: function() {
App.byId("feeds-holder_splitter").hide();
dijit.byId("main").resize();
Headlines.updateCurrentUnread();
},
onShow: function() {
App.byId("feeds-holder_splitter").show();
dijit.byId("main").resize();
Headlines.updateCurrentUnread();
},
init: function() {
console.log("in feedlist init");
this._feeds_holder_observer.observe(App.byId("feeds-holder"));
App.setLoadingProgress(50);
//document.onkeydown = (event) => { return App.hotkeyHandler(event) };
@ -215,7 +243,7 @@ const Feeds = {
if (hash_feed_id != undefined) {
this.open({feed: hash_feed_id, is_cat: hash_feed_is_cat});
} else {
this.open({feed: -3});
this.openDefaultFeed();
}
this.hideOrShowFeeds(App.getInitParam("hide_read_feeds"));
@ -260,10 +288,10 @@ const Feeds = {
// bw_limit disables timeout() so we request initial counters separately
if (App.getInitParam("bw_limit")) {
this.requestCounters(true);
this.requestCounters();
} else {
setTimeout(() => {
this.requestCounters(true);
this.requestCounters();
setInterval(() => { this.requestCounters(); }, 60 * 1000)
}, 250);
}
@ -284,8 +312,8 @@ const Feeds = {
this._active_feed_id = id;
this._active_feed_is_cat = is_cat;
$("headlines-frame").setAttribute("feed-id", id);
$("headlines-frame").setAttribute("is-cat", is_cat ? 1 : 0);
App.byId("headlines-frame").setAttribute("feed-id", id);
App.byId("headlines-frame").setAttribute("is-cat", is_cat ? 1 : 0);
this.select(id, is_cat);
@ -299,7 +327,7 @@ const Feeds = {
toggleUnread: function() {
const hide = !App.getInitParam("hide_read_feeds");
xhrPost("backend.php", {op: "rpc", method: "setpref", key: "HIDE_READ_FEEDS", value: hide}, () => {
xhr.post("backend.php", {op: "rpc", method: "setpref", key: "HIDE_READ_FEEDS", value: hide}, () => {
this.hideOrShowFeeds(hide);
App.setInitParam("hide_read_feeds", hide);
});
@ -310,14 +338,13 @@ const Feeds = {
if (tree)
return tree.hideRead(hide, App.getInitParam("hide_read_shows_special"));*/
$$("body")[0].setAttribute("hide-read-feeds", !!hide);
$$("body")[0].setAttribute("hide-read-shows-special", !!App.getInitParam("hide_read_shows_special"));
App.findAll("body")[0].setAttribute("hide-read-feeds", !!hide);
App.findAll("body")[0].setAttribute("hide-read-shows-special", !!App.getInitParam("hide_read_shows_special"));
},
open: function(params) {
const feed = params.feed;
const is_cat = !!params.is_cat || false;
const offset = params.offset || 0;
const viewfeed_debug = params.viewfeed_debug;
const append = params.append || false;
const method = params.method;
// this is used to quickly switch between feeds, sets active but xhr is on a timeout
@ -339,7 +366,7 @@ const Feeds = {
}, 10 * 1000);
}
Form.enable("toolbar-main");
//Form.enable("toolbar-main");
let query = Object.assign({op: "feeds", method: "view", feed: feed},
dojo.formToObject("toolbar-main"));
@ -362,8 +389,6 @@ const Feeds = {
query.m = "ForceUpdate";
}
Form.enable("toolbar-main");
if (!delayed)
if (!this.setExpando(feed, is_cat,
(is_cat) ? 'images/indicator_tiny.gif' : 'images/indicator_white.gif'))
@ -373,20 +398,13 @@ const Feeds = {
this.setActive(feed, is_cat);
if (viewfeed_debug) {
window.open("backend.php?" +
dojo.objectToQuery(
Object.assign({csrf_token: App.getInitParam("csrf_token")}, query)
));
}
window.clearTimeout(this._viewfeed_wait_timeout);
this._viewfeed_wait_timeout = window.setTimeout(() => {
xhrPost("backend.php", query, (transport) => {
xhr.json("backend.php", query, (reply) => {
try {
window.clearTimeout(this._infscroll_timeout);
this.setExpando(feed, is_cat, 'images/blank_icon.gif');
Headlines.onLoaded(transport, offset, append);
Headlines.onLoaded(reply, offset, append);
PluginHost.run(PluginHost.HOOK_FEED_LOADED, [feed, is_cat]);
} catch (e) {
App.Error.report(e);
@ -401,8 +419,7 @@ const Feeds = {
Notify.progress("Marking all feeds as read...");
xhrPost("backend.php", {op: "feeds", method: "catchupAll"}, () => {
this.requestCounters(true);
xhr.json("backend.php", {op: "feeds", method: "catchupAll"}, () => {
this.reloadCurrent();
});
@ -447,9 +464,7 @@ const Feeds = {
Notify.progress("Loading, please wait...", true);
xhrPost("backend.php", catchup_query, (transport) => {
App.handleRpcJson(transport);
xhr.json("backend.php", catchup_query, () => {
const show_next_feed = App.getInitParam("on_catchup_show_next_feed");
// only select next unread feed if catching up entirely (as opposed to last week etc)
@ -476,9 +491,9 @@ const Feeds = {
if (App.getInitParam("confirm_feed_catchup") != 1 || confirm(str)) {
const rows = $$("#headlines-frame > div[id*=RROW][class*=Unread][data-orig-feed-id='" + id + "']");
const rows = App.findAll("#headlines-frame > div[id*=RROW][class*=Unread][data-orig-feed-id='" + id + "']");
rows.each((row) => {
rows.forEach((row) => {
row.removeClassName("Unread");
})
}
@ -501,7 +516,7 @@ const Feeds = {
const tree = dijit.byId("feedTree");
if (tree && tree.model)
return tree.getFeedCategory(feed);
return tree._cat_of_feed(feed);
} catch (e) {
//
@ -566,14 +581,42 @@ const Feeds = {
return tree.model.store.getValue(nuf, 'bare_id');
},
search: function() {
xhrPost("backend.php",
{op: "feeds", method: "search",
param: Feeds.getActive() + ":" + Feeds.activeIsCat()},
(transport) => {
xhr.json("backend.php",
{op: "feeds", method: "search"},
(reply) => {
try {
const dialog = new fox.SingleUseDialog({
id: "searchDlg",
content: transport.responseText,
content: `
<form onsubmit='return false'>
<section>
<fieldset>
<input dojoType='dijit.form.ValidationTextBox' id='search_query'
style='font-size : 16px; width : 540px;'
placeHolder="${__("Search %s...").replace("%s", Feeds.getName(Feeds.getActive(), Feeds.activeIsCat()))}"
name='query' type='search' value=''>
</fieldset>
${reply.show_language ?
`
<fieldset>
<label class='inline'>${__("Language:")}</label>
${App.FormFields.select_tag("search_language", reply.default_language, reply.all_languages,
{title: __('Used for word stemming')}, "search_language")}
</fieldset>
` : ''}
</section>
<footer>
${reply.show_syntax_help ?
`${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("Search syntax"), "",
{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.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>
`,
title: __("Search"),
execute: function () {
if (this.validate()) {
@ -613,8 +656,13 @@ const Feeds = {
updateRandom: function() {
console.log("in update_random_feed");
xhrPost("backend.php", {op: "rpc", method: "updaterandomfeed"}, (transport) => {
App.handleRpcJson(transport, true);
xhr.json("backend.php", {op: "rpc", method: "updaterandomfeed"}, () => {
//
});
},
renderIcon: function(feed_id, exists) {
return feed_id && exists ?
`<img class="icon" src="${App.escapeHtml(App.getInitParam("icons_url"))}/${feed_id}.ico">` :
`<i class='icon-no-feed material-icons'>rss_feed</i>`;
}
};

@ -1,13 +1,13 @@
'use strict';
/* global __, ngettext, Article, App */
/* global xhrPost, dojo, dijit, PluginHost, Notify, $$, Feeds */
/* global dojo, dijit, PluginHost, Notify, xhr, Feeds */
/* global CommonDialogs */
const Headlines = {
vgroup_last_feed: undefined,
_headlines_scroll_timeout: 0,
_observer_counters_timeout: 0,
//_observer_counters_timeout: 0,
headlines: [],
current_first_id: 0,
_scroll_reset_timeout: false,
@ -44,7 +44,7 @@ const Headlines = {
row_observer: new MutationObserver((mutations) => {
const modified = [];
mutations.each((m) => {
mutations.forEach((m) => {
if (m.type == 'attributes' && ['class', 'data-score'].indexOf(m.attributeName) != -1) {
const row = m.target;
@ -54,7 +54,7 @@ const Headlines = {
const hl = Headlines.headlines[id];
if (hl) {
const hl_old = Object.extend({}, hl);
const hl_old = {...{}, ...hl};
hl.unread = row.hasClassName("Unread");
hl.marked = row.hasClassName("marked");
@ -94,7 +94,7 @@ const Headlines = {
rescore: {},
};
modified.each(function (m) {
modified.forEach(function (m) {
if (m.old.marked != m.new.marked)
ops.tmark.push(m.id);
@ -118,29 +118,29 @@ const Headlines = {
}
});
ops.select.each((row) => {
const cb = dijit.getEnclosingWidget(row.select(".rchk")[0]);
ops.select.forEach((row) => {
const cb = dijit.getEnclosingWidget(row.querySelector(".rchk"));
if (cb)
cb.attr('checked', true);
});
ops.deselect.each((row) => {
const cb = dijit.getEnclosingWidget(row.select(".rchk")[0]);
ops.deselect.forEach((row) => {
const cb = dijit.getEnclosingWidget(row.querySelector(".rchk"));
if (cb && !row.hasClassName("active"))
cb.attr('checked', false);
});
ops.activate.each((row) => {
const cb = dijit.getEnclosingWidget(row.select(".rchk")[0]);
ops.activate.forEach((row) => {
const cb = dijit.getEnclosingWidget(row.querySelector(".rchk"));
if (cb)
cb.attr('checked', true);
});
ops.deactivate.each((row) => {
const cb = dijit.getEnclosingWidget(row.select(".rchk")[0]);
ops.deactivate.forEach((row) => {
const cb = dijit.getEnclosingWidget(row.querySelector(".rchk"));
if (cb && !row.hasClassName("Selected"))
cb.attr('checked', false);
@ -149,39 +149,56 @@ const Headlines = {
const promises = [];
if (ops.tmark.length != 0)
promises.push(xhrPost("backend.php",
{op: "rpc", method: "markSelected", ids: ops.tmark.toString(), cmode: 2}));
promises.push(xhr.post("backend.php",
{op: "rpc", method: "markSelected", "ids[]": ops.tmark, cmode: 2}));
if (ops.tpub.length != 0)
promises.push(xhrPost("backend.php",
{op: "rpc", method: "publishSelected", ids: ops.tpub.toString(), cmode: 2}));
promises.push(xhr.post("backend.php",
{op: "rpc", method: "publishSelected", "ids[]": ops.tpub, cmode: 2}));
if (ops.read.length != 0)
promises.push(xhrPost("backend.php",
{op: "rpc", method: "catchupSelected", ids: ops.read.toString(), cmode: 0}));
promises.push(xhr.post("backend.php",
{op: "rpc", method: "catchupSelected", "ids[]": ops.read, cmode: 0}));
if (ops.unread.length != 0)
promises.push(xhrPost("backend.php",
{op: "rpc", method: "catchupSelected", ids: ops.unread.toString(), cmode: 1}));
promises.push(xhr.post("backend.php",
{op: "rpc", method: "catchupSelected", "ids[]": ops.unread, cmode: 1}));
const scores = Object.keys(ops.rescore);
if (scores.length != 0) {
scores.each((score) => {
promises.push(xhrPost("backend.php",
{op: "article", method: "setScore", id: ops.rescore[score].toString(), score: score}));
scores.forEach((score) => {
promises.push(xhr.post("backend.php",
{op: "article", method: "setScore", "ids[]": ops.rescore[score].toString(), score: score}));
});
}
if (promises.length > 0)
Promise.all([promises]).then(() => {
window.clearTimeout(this._observer_counters_timeout);
Promise.all(promises).then((results) => {
let feeds = [];
let labels = [];
this._observer_counters_timeout = setTimeout(() => {
Feeds.requestCounters(true);
}, 1000);
results.forEach((res) => {
if (res) {
try {
const obj = JSON.parse(res);
if (obj.feeds)
feeds = feeds.concat(obj.feeds);
if (obj.labels)
labels = labels.concat(obj.labels);
} catch (e) {
console.warn(e, res);
}
}
});
if (feeds.length > 0) {
console.log('requesting counters for', feeds, labels);
Feeds.requestCounters(feeds, labels);
}
});
},
click: function (event, id, in_body) {
in_body = in_body || false;
@ -211,7 +228,7 @@ const Headlines = {
Headlines.select('none');
const scroll_position_A = $("RROW-" + id).offsetTop - $("headlines-frame").scrollTop;
const scroll_position_A = App.byId(`RROW-${id}`).offsetTop - App.byId("headlines-frame").scrollTop;
Article.setActive(id);
@ -222,10 +239,10 @@ const Headlines = {
Headlines.toggleUnread(id, 0);
} else {
const scroll_position_B = $("RROW-" + id).offsetTop - $("headlines-frame").scrollTop;
const scroll_position_B = App.byId(`RROW-${id}`).offsetTop - App.byId("headlines-frame").scrollTop;
// this would only work if there's enough space
$("headlines-frame").scrollTop -= scroll_position_A-scroll_position_B;
App.byId("headlines-frame").scrollTop -= scroll_position_A-scroll_position_B;
Article.cdmMoveToId(id);
}
@ -252,7 +269,7 @@ const Headlines = {
return false;
},
initScrollHandler: function () {
$("headlines-frame").onscroll = (event) => {
App.byId("headlines-frame").onscroll = (event) => {
clearTimeout(this._headlines_scroll_timeout);
this._headlines_scroll_timeout = window.setTimeout(function () {
//console.log('done scrolling', event);
@ -262,8 +279,8 @@ const Headlines = {
},
loadMore: function () {
const view_mode = document.forms["toolbar-main"].view_mode.value;
const unread_in_buffer = $$("#headlines-frame > div[id*=RROW][class*=Unread]").length;
const num_all = $$("#headlines-frame > div[id*=RROW]").length;
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());
// TODO implement marked & published
@ -289,10 +306,10 @@ const Headlines = {
Feeds.open({feed: Feeds.getActive(), is_cat: Feeds.activeIsCat(), offset: offset, append: true});
},
isChildVisible: function (elem) {
return App.Scrollable.isChildVisible(elem, $("headlines-frame"));
return App.Scrollable.isChildVisible(elem, App.byId("headlines-frame"));
},
firstVisible: function () {
const rows = $$("#headlines-frame > div[id*=RROW]");
const rows = App.findAll("#headlines-frame > div[id*=RROW]");
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
@ -303,7 +320,7 @@ const Headlines = {
}
},
unpackVisible: function(container) {
const rows = $$("#headlines-frame > div[id*=RROW][data-content].cdm");
const rows = App.findAll("#headlines-frame > div[id*=RROW][data-content].cdm");
for (let i = 0; i < rows.length; i++) {
if (App.Scrollable.isChildVisible(rows[i], container)) {
@ -315,8 +332,8 @@ const Headlines = {
scrollHandler: function (/*event*/) {
try {
if (!Feeds.infscroll_disabled && !Feeds.infscroll_in_progress) {
const hsp = $("headlines-spacer");
const container = $("headlines-frame");
const hsp = App.byId("headlines-spacer");
const container = App.byId("headlines-frame");
if (hsp && hsp.previousSibling) {
const last_row = hsp.previousSibling;
@ -333,7 +350,7 @@ const Headlines = {
}
if (App.isCombinedMode() && App.getInitParam("cdm_expanded")) {
const container = $("headlines-frame")
const container = App.byId("headlines-frame")
/* don't do anything until there was some scrolling */
if (container.scrollTop > 0)
@ -342,12 +359,12 @@ const Headlines = {
if (App.getInitParam("cdm_auto_catchup")) {
const rows = $$("#headlines-frame > div[id*=RROW][class*=Unread]");
const rows = App.findAll("#headlines-frame > div[id*=RROW][class*=Unread]");
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if ($("headlines-frame").scrollTop > (row.offsetTop + row.offsetHeight / 2)) {
if (App.byId("headlines-frame").scrollTop > (row.offsetTop + row.offsetHeight / 2)) {
row.removeClassName("Unread");
} else {
break;
@ -362,23 +379,23 @@ const Headlines = {
return this.headlines[id];
},
setCommonClasses: function () {
$("headlines-frame").removeClassName("cdm");
$("headlines-frame").removeClassName("normal");
App.byId("headlines-frame").removeClassName("cdm");
App.byId("headlines-frame").removeClassName("normal");
$("headlines-frame").addClassName(App.isCombinedMode() ? "cdm" : "normal");
App.byId("headlines-frame").addClassName(App.isCombinedMode() ? "cdm" : "normal");
// for floating title because it's placed outside of headlines-frame
$("main").removeClassName("expandable");
$("main").removeClassName("expanded");
App.byId("main").removeClassName("expandable");
App.byId("main").removeClassName("expanded");
if (App.isCombinedMode())
$("main").addClassName(App.getInitParam("cdm_expanded") ? " expanded" : " expandable");
App.byId("main").addClassName(App.getInitParam("cdm_expanded") ? "expanded" : "expandable");
},
renderAgain: function () {
// TODO: wrap headline elements into a knockoutjs model to prevent all this stuff
Headlines.setCommonClasses();
$$("#headlines-frame > div[id*=RROW]").each((row) => {
App.findAll("#headlines-frame > div[id*=RROW]").forEach((row) => {
const id = row.getAttribute("data-article-id");
const hl = this.headlines[id];
@ -401,12 +418,12 @@ const Headlines = {
}
});
$$(".cdm .header-sticky-guard").each((e) => {
App.findAll(".cdm .header-sticky-guard").forEach((e) => {
this.sticky_header_observer.observe(e)
});
if (App.getInitParam("cdm_expanded"))
$$("#headlines-frame > div[id*=RROW].cdm").each((e) => {
App.findAll("#headlines-frame > div[id*=RROW].cdm").forEach((e) => {
this.unpack_observer.observe(e)
});
@ -423,7 +440,7 @@ const Headlines = {
if (headlines.vfeed_group_enabled && hl.feed_title && this.vgroup_last_feed != hl.feed_id) {
const vgrhdr = `<div data-feed-id='${hl.feed_id}' class='feed-title'>
<div style='float : right'>${hl.feed_icon}</div>
<div style='float : right'>${Feeds.renderIcon(hl.feed_id, hl.has_icon)}</div>
<a class="title" href="#" onclick="Feeds.open({feed:${hl.feed_id}})">${hl.feed_title}
<a class="catchup" title="${__('mark feed as read')}" onclick="Feeds.catchupFeedInGroup(${hl.feed_id})" href="#"><i class="icon-done material-icons">done_all</i></a>
</div>`
@ -431,7 +448,7 @@ const Headlines = {
const tmp = document.createElement("div");
tmp.innerHTML = vgrhdr;
$("headlines-frame").appendChild(tmp.firstChild);
App.byId("headlines-frame").appendChild(tmp.firstChild);
this.vgroup_last_feed = hl.feed_id;
}
@ -462,7 +479,7 @@ const Headlines = {
<a class="title" title="${App.escapeHtml(hl.title)}" target="_blank" rel="noopener noreferrer" href="${App.escapeHtml(hl.link)}">
${hl.title}</a>
<span class="author">${hl.author}</span>
${hl.labels}
${Article.renderLabels(hl.id, hl.labels)}
${hl.cdm_excerpt ? hl.cdm_excerpt : ""}
</span>
@ -477,25 +494,26 @@ const Headlines = {
<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}})">
${hl.feed_icon}</span>
${Feeds.renderIcon(hl.feed_id, hl.has_icon)}
</span>
</div>
</div>
<div class="content" onclick="return Headlines.click(event, ${hl.id}, true);">
<div id="POSTNOTE-${hl.id}">${hl.note}</div>
${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">
${hl.enclosures}
${Article.renderEnclosures(hl.enclosures)}
</div>
<div class="footer" onclick="event.stopPropagation()">
<div class="left">
${hl.buttons_left}
<i class="material-icons">label_outline</i>
<span id="ATSTR-${hl.id}">${hl.tags_str}</span>
${Article.renderTags(hl.id, hl.tags)}
<a title="${__("Edit tags for this article")}" href="#"
onclick="Article.editTags(${hl.id})">(+)</a>
${comments}
@ -527,7 +545,7 @@ const Headlines = {
<span data-article-id="${hl.id}" class="hl-content hlMenuAttach">
<a class="title" href="${App.escapeHtml(hl.link)}">${hl.title} <span class="preview">${hl.content_preview}</span></a>
<span class="author">${hl.author}</span>
${hl.labels}
${Article.renderLabels(hl.id, hl.labels)}
</span>
</div>
<span class="feed">
@ -538,7 +556,7 @@ const Headlines = {
</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)}">${hl.feed_icon}</span>
<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>
</div>
</div>
`;
@ -555,20 +573,74 @@ const Headlines = {
return tmp.firstChild;
},
updateCurrentUnread: function () {
if ($("feed_current_unread")) {
if (App.byId("feed_current_unread")) {
const feed_unread = Feeds.getUnread(Feeds.getActive(), Feeds.activeIsCat());
if (feed_unread > 0 && !Element.visible("feeds-holder")) {
$("feed_current_unread").innerText = feed_unread;
App.byId("feed_current_unread").innerText = feed_unread;
Element.show("feed_current_unread");
} else {
Element.hide("feed_current_unread");
}
}
},
onLoaded: function (transport, offset, append) {
const reply = App.handleRpcJson(transport);
renderToolbar: function(headlines) {
const tb = headlines['toolbar'];
const search_query = Feeds._search_query ? Feeds._search_query.query : "";
const target = dijit.byId('toolbar-headlines');
if (tb && typeof tb == 'object') {
target.attr('innerHTML',
`
<span class='left'>
<a href="#" title="${__("Show as feed")}"
onclick='CommonDialogs.generatedFeed("${headlines.id}", ${headlines.is_cat}, "${App.escapeHtml(search_query)}")'>
<i class='icon-syndicate material-icons'>rss_feed</i>
</a>
${tb.site_url ?
`<a class="feed_title" target="_blank" href="${App.escapeHtml(tb.site_url)}" title="${tb.last_updated}">${tb.title}</a>` :
`<span class="feed_title">${tb.title}</span>`}
${search_query ?
`
<span class='cancel_search'>(<a href='#' onclick='Feeds.cancelSearch()'>${__("Cancel search")}</a>)</span>
` : ''}
${tb.error ? `<i title="${App.escapeHtml(tb.error)}" class='material-icons icon-error'>error</i>` : ''}
<span id='feed_current_unread' style='display: none'></span>
</span>
<span class='right'>
<span id='selected_prompt'></span>
<div 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>
<div dojoType='dijit.MenuItem' onclick='Headlines.select("unread")'>${__('Unread')}</div>
<div dojoType='dijit.MenuItem' onclick='Headlines.select("invert")'>${__('Invert')}</div>
<div dojoType='dijit.MenuItem' onclick='Headlines.select("none")'>${__('None')}</div>
<div dojoType='dijit.MenuSeparator'></div>
<div dojoType='dijit.MenuItem' onclick='Headlines.selectionToggleUnread()'>${__('Toggle unread')}</div>
<div dojoType='dijit.MenuItem' onclick='Headlines.selectionToggleMarked()'>${__('Toggle starred')}</div>
<div dojoType='dijit.MenuItem' onclick='Headlines.selectionTogglePublished()'>${__('Toggle published')}</div>
<div dojoType='dijit.MenuSeparator'></div>
<div dojoType='dijit.MenuItem' onclick='Headlines.catchupSelection()'>${__('Mark as read')}</div>
<div dojoType='dijit.MenuItem' onclick='Article.selectionSetScore()'>${__('Set score')}</div>
${tb.plugin_menu_items}
${headlines.id === 0 && !headlines.is_cat ?
`
<div dojoType='dijit.MenuSeparator'></div>
<div dojoType='dijit.MenuItem' class='text-error' onclick='Headlines.deleteSelection()'>${__('Delete permanently')}</div>
` : ''}
</div>
${tb.plugin_buttons}
</span>
`);
} else {
target.attr('innerHTML', '');
}
dojo.parser.parse(target.domNode);
},
onLoaded: function (reply, offset, append) {
console.log("Headlines.onLoaded: offset=", offset, "append=", append);
let is_cat = false;
@ -597,15 +669,15 @@ const Headlines = {
// also called in renderAgain() after view mode switch
Headlines.setCommonClasses();
$("headlines-frame").setAttribute("is-vfeed",
App.byId("headlines-frame").setAttribute("is-vfeed",
reply['headlines']['is_vfeed'] ? 1 : 0);
Article.setActive(0);
try {
$("headlines-frame").removeClassName("smooth-scroll");
$("headlines-frame").scrollTop = 0;
$("headlines-frame").addClassName("smooth-scroll");
App.byId("headlines-frame").removeClassName("smooth-scroll");
App.byId("headlines-frame").scrollTop = 0;
App.byId("headlines-frame").addClassName("smooth-scroll");
} catch (e) {
console.warn(e);
}
@ -613,25 +685,27 @@ const Headlines = {
this.headlines = [];
this.vgroup_last_feed = undefined;
dojo.html.set($("toolbar-headlines"),
/*dojo.html.set(App.byId("toolbar-headlines"),
reply['headlines']['toolbar'],
{parseContent: true});
{parseContent: true});*/
Headlines.renderToolbar(reply['headlines']);
if (typeof reply['headlines']['content'] == 'string') {
$("headlines-frame").innerHTML = reply['headlines']['content'];
App.byId("headlines-frame").innerHTML = reply['headlines']['content'];
} else {
$("headlines-frame").innerHTML = '';
App.byId("headlines-frame").innerHTML = '';
for (let i = 0; i < reply['headlines']['content'].length; i++) {
const hl = reply['headlines']['content'][i];
$("headlines-frame").appendChild(this.render(reply['headlines'], hl));
App.byId("headlines-frame").appendChild(this.render(reply['headlines'], hl));
this.headlines[parseInt(hl.id)] = hl;
}
}
let hsp = $("headlines-spacer");
let hsp = App.byId("headlines-spacer");
if (!hsp) {
hsp = document.createElement("div");
@ -646,18 +720,19 @@ const Headlines = {
hsp.innerHTML = "<a href='#' onclick='Feeds.openNextUnread()'>" +
__("Click to open next unread feed.") + "</a>";
/*
if (Feeds._search_query) {
$("feed_title").innerHTML += "<span id='cancel_search'>" +
App.byId("feed_title").innerHTML += "<span id='cancel_search'>" +
" (<a href='#' onclick='Feeds.cancelSearch()'>" + __("Cancel search") + "</a>)" +
"</span>";
}
} */
Headlines.updateCurrentUnread();
} else if (headlines_count > 0 && feed_id == Feeds.getActive() && is_cat == Feeds.activeIsCat()) {
const c = dijit.byId("headlines-frame");
let hsp = $("headlines-spacer");
let hsp = App.byId("headlines-spacer");
if (hsp)
c.domNode.removeChild(hsp);
@ -665,13 +740,13 @@ const Headlines = {
let headlines_appended = 0;
if (typeof reply['headlines']['content'] == 'string') {
$("headlines-frame").innerHTML = reply['headlines']['content'];
App.byId("headlines-frame").innerHTML = reply['headlines']['content'];
} else {
for (let i = 0; i < reply['headlines']['content'].length; i++) {
const hl = reply['headlines']['content'][i];
if (!this.headlines[parseInt(hl.id)]) {
$("headlines-frame").appendChild(this.render(reply['headlines'], hl));
App.byId("headlines-frame").appendChild(this.render(reply['headlines'], hl));
this.headlines[parseInt(hl.id)] = hl;
++headlines_appended;
@ -703,7 +778,7 @@ const Headlines = {
console.log("no headlines received, infscroll_disabled=", Feeds.infscroll_disabled, 'first_id_changed=', first_id_changed);
const hsp = $("headlines-spacer");
const hsp = App.byId("headlines-spacer");
if (hsp) {
if (first_id_changed) {
@ -716,17 +791,16 @@ const Headlines = {
}
}
$$(".cdm .header-sticky-guard").each((e) => {
App.findAll(".cdm .header-sticky-guard").forEach((e) => {
this.sticky_header_observer.observe(e)
});
if (App.getInitParam("cdm_expanded"))
$$("#headlines-frame > div[id*=RROW].cdm").each((e) => {
App.findAll("#headlines-frame > div[id*=RROW].cdm").forEach((e) => {
this.unpack_observer.observe(e)
});
} else {
console.error("Invalid object received: " + transport.responseText);
dijit.byId("headlines-frame").attr('content', "<div class='whiteBox'>" +
__('Could not update headlines (invalid object received - see error console for details)') +
"</div>");
@ -755,9 +829,7 @@ const Headlines = {
Feeds.reloadCurrent();
},
selectionToggleUnread: function (params) {
params = params || {};
selectionToggleUnread: function (params = {}) {
const cmode = params.cmode != undefined ? params.cmode : 2;
const no_error = params.no_error || false;
const ids = params.ids || Headlines.getSelected();
@ -769,8 +841,8 @@ const Headlines = {
return;
}
ids.each((id) => {
const row = $("RROW-" + id);
ids.forEach((id) => {
const row = App.byId(`RROW-${id}`);
if (row) {
switch (cmode) {
@ -794,7 +866,7 @@ const Headlines = {
return;
}
ids.each((id) => {
ids.forEach((id) => {
this.toggleMark(id);
});
},
@ -806,26 +878,24 @@ const Headlines = {
return;
}
ids.each((id) => {
ids.forEach((id) => {
this.togglePub(id);
});
},
toggleMark: function (id) {
const row = $("RROW-" + id);
const row = App.byId(`RROW-${id}`);
if (row)
row.toggleClassName("marked");
},
togglePub: function (id) {
const row = $("RROW-" + id);
const row = App.byId(`RROW-${id}`);
if (row)
row.toggleClassName("published");
},
move: function (mode, params) {
params = params || {};
move: function (mode, params = {}) {
const no_expand = params.no_expand || false;
const force_previous = params.force_previous || this.default_force_previous;
const force_to_top = params.force_to_top || this.default_force_to_top;
@ -834,7 +904,7 @@ const Headlines = {
let next_id = false;
let current_id = Article.getActive();
if (!Headlines.isChildVisible($("RROW-" + current_id))) {
if (!Headlines.isChildVisible(App.byId(`RROW-${current_id}`))) {
console.log('active article is obscured, resetting to first visible...');
current_id = Headlines.firstVisible();
prev_id = current_id;
@ -871,13 +941,30 @@ const Headlines = {
} else {
Article.view(next_id, no_expand);
}
} else if (App.isCombinedMode()) {
// try to show hsp if no next article exists, in case there's useful information like first_id_changed etc
const row = App.byId(`RROW-${current_id}`);
const ctr = App.byId("headlines-frame");
if (row) {
const next = row.nextSibling;
// hsp has half-screen height in auto catchup mode therefore we use its first child (normally A element)
if (next && Element.visible(next) && next.id == "headlines-spacer" && next.firstChild) {
const offset = App.byId("headlines-spacer").offsetTop - App.byId("headlines-frame").offsetHeight + next.firstChild.offsetHeight;
// don't jump back either
if (ctr.scrollTop < offset)
ctr.scrollTop = offset;
}
}
}
} else if (mode === "prev") {
if (prev_id || current_id) {
if (App.isCombinedMode()) {
window.requestAnimationFrame(() => {
const row = $("RROW-" + current_id);
const ctr = $("headlines-frame");
const row = App.byId(`RROW-${current_id}`);
const ctr = App.byId("headlines-frame");
const delta_px = Math.round(row.offsetTop) - Math.round(ctr.scrollTop);
console.log('moving back, delta_px', delta_px);
@ -898,7 +985,7 @@ const Headlines = {
},
updateSelectedPrompt: function () {
const count = Headlines.getSelected().length;
const elem = $("selected_prompt");
const elem = App.byId("selected_prompt");
if (elem) {
elem.innerHTML = ngettext("%d article selected",
@ -908,7 +995,7 @@ const Headlines = {
}
},
toggleUnread: function (id, cmode) {
const row = $("RROW-" + id);
const row = App.byId(`RROW-${id}`);
if (row) {
if (typeof cmode == "undefined") cmode = 2;
@ -939,9 +1026,8 @@ const Headlines = {
ids: ids.toString(), lid: id
};
xhrPost("backend.php", query, (transport) => {
App.handleRpcJson(transport);
this.onLabelsUpdated(transport);
xhr.json("backend.php", query, (reply) => {
this.onLabelsUpdated(reply);
});
},
selectionAssignLabel: function (id, ids) {
@ -957,9 +1043,8 @@ const Headlines = {
ids: ids.toString(), lid: id
};
xhrPost("backend.php", query, (transport) => {
App.handleRpcJson(transport);
this.onLabelsUpdated(transport);
xhr.json("backend.php", query, (reply) => {
this.onLabelsUpdated(reply);
});
},
deleteSelection: function () {
@ -988,15 +1073,14 @@ const Headlines = {
const query = {op: "rpc", method: "delete", ids: rows.toString()};
xhrPost("backend.php", query, (transport) => {
App.handleRpcJson(transport);
xhr.json("backend.php", query, () => {
Feeds.reloadCurrent();
});
},
getSelected: function () {
const rv = [];
$$("#headlines-frame > div[id*=RROW][class*=Selected]").each(
App.findAll("#headlines-frame > div[id*=RROW][class*=Selected]").forEach(
function (child) {
rv.push(child.getAttribute("data-article-id"));
});
@ -1010,9 +1094,9 @@ const Headlines = {
getLoaded: function () {
const rv = [];
const children = $$("#headlines-frame > div[id*=RROW-]");
const children = App.findAll("#headlines-frame > div[id*=RROW-]");
children.each(function (child) {
children.forEach(function (child) {
if (Element.visible(child)) {
rv.push(child.getAttribute("data-article-id"));
}
@ -1021,7 +1105,7 @@ const Headlines = {
return rv;
},
onRowChecked: function (elem) {
const row = elem.domNode.up("div[id*=RROW]");
const row = elem.domNode.closest("div[id*=RROW]");
// do not allow unchecking active article checkbox
if (row.hasClassName("active")) {
@ -1039,7 +1123,7 @@ const Headlines = {
if (start == stop)
return [start];
const rows = $$("#headlines-frame > div[id*=RROW]");
const rows = App.findAll("#headlines-frame > div[id*=RROW]");
const results = [];
let collecting = false;
@ -1066,7 +1150,7 @@ const Headlines = {
// mode = all,none,unread,invert,marked,published
let query = "#headlines-frame > div[id*=RROW]";
if (articleId) query += "[data-article-id=" + articleId + "]";
if (articleId) query += `[data-article-id="${articleId}"]`;
switch (mode) {
case "none":
@ -1086,10 +1170,7 @@ const Headlines = {
console.warn("select: unknown mode", mode);
}
const rows = $$(query);
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
App.findAll(query).forEach((row) => {
switch (mode) {
case "none":
@ -1101,7 +1182,7 @@ const Headlines = {
default:
row.addClassName("Selected");
}
}
});
},
catchupSelection: function () {
const rows = Headlines.getSelected();
@ -1140,7 +1221,7 @@ const Headlines = {
if (!below) {
for (let i = 0; i < visible_ids.length; i++) {
if (visible_ids[i] != id) {
const e = $("RROW-" + visible_ids[i]);
const e = App.byId(`RROW-${visible_ids[i]}`);
if (e && e.hasClassName("Unread")) {
ids_to_mark.push(visible_ids[i]);
@ -1152,7 +1233,7 @@ const Headlines = {
} else {
for (let i = visible_ids.length - 1; i >= 0; i--) {
if (visible_ids[i] != id) {
const e = $("RROW-" + visible_ids[i]);
const e = App.byId(`RROW-${visible_ids[i]}`);
if (e && e.hasClassName("Unread")) {
ids_to_mark.push(visible_ids[i]);
@ -1171,26 +1252,40 @@ const Headlines = {
if (App.getInitParam("confirm_feed_catchup") != 1 || confirm(msg)) {
for (let i = 0; i < ids_to_mark.length; i++) {
const e = $("RROW-" + ids_to_mark[i]);
const e = App.byId(`RROW-${ids_to_mark[i]}`);
e.removeClassName("Unread");
}
}
}
},
onLabelsUpdated: function (transport) {
const data = JSON.parse(transport.responseText);
onTagsUpdated: function (data) {
if (data) {
if (this.headlines[data.id]) {
this.headlines[data.id].tags = data.tags;
}
App.findAll(`span[data-tags-for="${data.id}"`).forEach((ctr) => {
ctr.innerHTML = Article.renderTags(data.id, data.tags);
});
}
},
// TODO: maybe this should cause article to be rendered again, although it might cause flicker etc
onLabelsUpdated: function (data) {
if (data) {
data['info-for-headlines'].each(function (elem) {
$$(".HLLCTR-" + elem.id).each(function (ctr) {
ctr.innerHTML = elem.labels;
data["labels-for"].forEach((row) => {
if (this.headlines[row.id]) {
this.headlines[row.id].labels = row.labels;
}
App.findAll(`span[data-labels-for="${row.id}"]`).forEach((ctr) => {
ctr.innerHTML = Article.renderLabels(row.id, row.labels);
});
});
}
},
scrollToArticleId: function (id) {
const container = $("headlines-frame");
const row = $("RROW-" + id);
const container = App.byId("headlines-frame");
const row = App.byId(`RROW-${id}`);
if (!container || !row) return;
@ -1289,7 +1384,7 @@ const Headlines = {
const labelAddMenu = new dijit.Menu({ownerMenu: menu});
const labelDelMenu = new dijit.Menu({ownerMenu: menu});
labels.each(function (label) {
labels.forEach(function (label) {
const bare_id = label.id;
const name = label.caption;
@ -1337,10 +1432,10 @@ const Headlines = {
}
},
scrollByPages: function (page_offset) {
App.Scrollable.scrollByPages($("headlines-frame"), page_offset);
App.Scrollable.scrollByPages(App.byId("headlines-frame"), page_offset);
},
scroll: function (offset) {
App.Scrollable.scroll($("headlines-frame"), offset);
App.Scrollable.scroll(App.byId("headlines-frame"), offset);
},
initHeadlinesMenu: function () {
if (!dijit.byId("headlinesMenu")) {

@ -1,9 +1,44 @@
/* eslint-disable prefer-rest-params */
/* global __, lib, dijit, define, dojo, CommonDialogs, Notify, Tables, xhrPost, fox, App */
/* global __, lib, dijit, define, dojo, CommonDialogs, Notify, Tables, xhrPost, xhr, fox, App */
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], function (declare, domConstruct) {
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dojo/_base/array", "dojo/cookie"],
function (declare, domConstruct, checkBoxTree, array, cookie) {
return declare("fox.PrefFeedTree", lib.CheckBoxTree, {
// save state in localStorage instead of cookies
// reference: https://stackoverflow.com/a/27968996
_saveExpandedNodes: function(){
if (this.persist && this.cookieName){
const ary = [];
for (const id in this._openedNodes){
ary.push(id);
}
// Was:
// cookie(this.cookieName, ary.join(","), {expires: 365});
localStorage.setItem(this.cookieName, ary.join(","));
}
},
_initState: function(){
this.cookieName = 'prefs:' + this.cookieName;
// summary:
// Load in which nodes should be opened automatically
this._openedNodes = {};
if (this.persist && this.cookieName){
// Was:
// var oreo = cookie(this.cookieName);
let oreo = localStorage.getItem(this.cookieName);
// migrate old data if nothing in localStorage
if (oreo == null || oreo === '') {
oreo = cookie(this.cookieName);
cookie(this.cookieName, null, { expires: -1 });
}
if (oreo){
array.forEach(oreo.split(','), function(item){
this._openedNodes[item] = true;
}, this);
}
}
},
_createTreeNode: function(args) {
const tnode = this.inherited(arguments);
@ -91,11 +126,11 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
return (!item || this.model.store.getValue(item, 'type') == 'category') ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "feed-icon";
},
reload: function() {
const searchElem = $("feed_search");
const searchElem = App.byId("feed_search");
const search = (searchElem) ? searchElem.value : "";
xhrPost("backend.php", { op: "pref-feeds", search: search }, (transport) => {
dijit.byId('feedsTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-feeds", search: search }, (reply) => {
dijit.byId('feedsTab').attr('content', reply);
Notify.close();
});
},
@ -129,14 +164,14 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
resetFeedOrder: function() {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-feeds", method: "feedsortreset"}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "feedsortreset"}, () => {
this.reload();
});
},
resetCatOrder: function() {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-feeds", method: "catsortreset"}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "catsortreset"}, () => {
this.reload();
});
},
@ -144,7 +179,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
if (confirm(__("Remove category %s? Any nested feeds would be placed into Uncategorized.").replace("%s", item.name))) {
Notify.progress("Removing category...");
xhrPost("backend.php", {op: "pref-feeds", method: "removeCat", ids: id}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "removeCat", ids: id}, () => {
Notify.close();
this.reload();
});
@ -163,7 +198,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -174,9 +209,16 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
return false;
},
checkErrorFeeds: function() {
xhr.json("backend.php", {op: "pref-feeds", method: "feedsWithErrors"}, (reply) => {
if (reply.length > 0) {
Element.show(dijit.byId("pref_feeds_errors_btn").domNode);
}
});
},
checkInactiveFeeds: function() {
xhrPost("backend.php", {op: "pref-feeds", method: "getinactivefeeds"}, (transport) => {
if (parseInt(transport.responseText) > 0) {
xhr.json("backend.php", {op: "pref-feeds", method: "inactivefeeds"}, (reply) => {
if (reply.length > 0) {
Element.show(dijit.byId("pref_feeds_inactive_btn").domNode);
}
});
@ -186,7 +228,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function (item) {
items.forEach(function (item) {
if (item.id[0].match("CAT:"))
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
@ -205,7 +247,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -220,7 +262,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function (item) {
items.forEach(function (item) {
if (item.id[0].match("FEED:"))
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
@ -253,16 +295,15 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-feeds", method: "editfeeds", ids: rows.toString()}, (transport) => {
xhr.post("backend.php", {op: "pref-feeds", method: "editfeeds", ids: rows.toString()}, (reply) => {
Notify.close();
try {
const dialog = new fox.SingleUseDialog({
id: "feedEditDlg",
title: __("Edit Multiple Feeds"),
getChildByName: function (name) {
/*getChildByName: function (name) {
let rv = null;
this.getChildren().each(
this.getChildren().forEach(
function (child) {
if (child.name == name) {
rv = child;
@ -270,16 +311,22 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
}
});
return rv;
},
toggleField: function (checkbox, elem, label) {
this.getChildByName(elem).attr('disabled', !checkbox.checked);
},*/
toggleField: function (checkbox) {
const name = checkbox.attr("data-control-for");
const target = dijit.getEnclosingWidget(dialog.domNode.querySelector(`input[name="${name}"]`));
if ($(label))
if (checkbox.checked)
$(label).removeClassName('text-muted');
else
$(label).addClassName('text-muted');
target.attr('disabled', !checkbox.attr('checked'));
console.log(target, target.attr('type'));
if (target.attr('type') == "checkbox") {
const label = checkbox.domNode.closest("label");
if (checkbox.attr('checked'))
label.removeClassName('text-muted');
else
label.addClassName('text-muted');
}
},
execute: function () {
if (this.validate() && confirm(__("Save changes to selected feeds?"))) {
@ -287,7 +334,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
/* normalize unchecked checkboxes because [] is not serialized */
Object.keys(query).each((key) => {
Object.keys(query).forEach((key) => {
const val = query[key];
if (typeof val == "object" && val.length == 0)
@ -296,7 +343,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
Notify.progress("Saving data...", true);
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
dialog.hide();
const tree = dijit.byId("feedTree");
@ -305,7 +352,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
});
}
},
content: transport.responseText
content: reply
});
dialog.show();
@ -325,7 +372,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
Notify.progress("Loading, please wait...");
xhrPost("backend.php", { op: 'pref-feeds', method: 'renamecat', id: id, title: new_name }, () => {
xhr.post("backend.php", { op: 'pref-feeds', method: 'renamecat', id: id, title: new_name }, () => {
this.reload();
});
}
@ -336,63 +383,22 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
if (title) {
Notify.progress("Creating category...");
xhrPost("backend.php", {op: "pref-feeds", method: "addCat", cat: title}, () => {
xhr.post("backend.php", {op: "pref-feeds", method: "addCat", cat: title}, () => {
Notify.close();
this.reload();
});
}
},
batchSubscribe: function() {
const dialog = new fox.SingleUseDialog({
id: "batchSubDlg",
title: __("Batch subscribe"),
execute: function () {
if (this.validate()) {
Notify.progress(__("Subscribing to feeds..."), true);
xhrPost("backend.php", this.attr('value'), () => {
Notify.close();
const tree = dijit.byId("feedTree");
if (tree) tree.reload();
dialog.hide();
});
}
},
content: __("Loading, please wait...")
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
xhrPost("backend.php", {op: 'pref-feeds', method: 'batchSubscribe'}, (transport) => {
dialog.attr('content', transport.responseText);
})
});
dialog.show();
},
showInactiveFeeds: function() {
const dialog = new fox.SingleUseDialog({
id: "inactiveFeedsDlg",
title: __("Feeds without recent updates"),
getSelectedFeeds: function () {
return Tables.getSelected("inactive-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.json("backend.php", {op: 'pref-feeds', method: 'batchSubscribe'}, (reply) => {
const dialog = new fox.SingleUseDialog({
id: "batchSubDlg",
title: __("Batch subscribe"),
execute: function () {
if (this.validate()) {
Notify.progress(__("Subscribing to feeds..."), true);
xhr.post("backend.php", this.attr('value'), () => {
Notify.close();
const tree = dijit.byId("feedTree");
@ -401,23 +407,143 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
dialog.hide();
});
}
},
content: `
<form onsubmit='return false'>
${App.FormFields.hidden_tag("op", "pref-feeds")}
${App.FormFields.hidden_tag("method", "batchaddfeeds")}
<header class='horizontal'>
${__("One valid feed per line (no detection is done)")}
</header>
<section>
<textarea style='font-size : 12px; width : 98%; height: 200px;'
dojoType='fox.form.ValidationTextArea' required='1' name='feeds'></textarea>
${reply.enable_cats ?
`<fieldset>
<label>${__('Place in category:')}</label>
${reply.cat_select}
</fieldset>
` : ''
}
</section>
<div id='feedDlg_loginContainer' style='display : none'>
<header>${__("Authentication")}</header>
<section>
<input dojoType='dijit.form.TextBox' name='login' placeHolder="${__("Login")}">
<input placeHolder="${__("Password")}" dojoType="dijit.form.TextBox" type='password'
autocomplete='new-password' name='pass'></div>
</section>
</div>
<fieldset class='narrow'>
<label class='checkbox'><input type='checkbox' name='need_auth' dojoType='dijit.form.CheckBox'
onclick='App.displayIfChecked(this, "feedDlg_loginContainer")'>
${__('Feeds require authentication.')}
</label>
</fieldset>
<footer>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).execute()' type='submit' class='alt-primary'>
${__('Subscribe')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
</form>
`
});
dialog.show();
} else {
alert(__("No feeds selected."));
}
},
content: __("Loading, please wait...")
});
},
showInactiveFeeds: function() {
xhr.json("backend.php", {op: 'pref-feeds', method: 'inactivefeeds'}, function (reply) {
const dialog = new fox.SingleUseDialog({
id: "inactiveFeedsDlg",
title: __("Feeds without recent updates"),
getSelectedFeeds: function () {
return Tables.getSelected("inactive-feeds-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedFeeds();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected feeds?"))) {
Notify.progress("Removing selected feeds...", true);
const query = {
op: "pref-feeds", method: "remove",
ids: sel_rows.toString()
};
xhr.post("backend.php", query, () => {
Notify.close();
const tree = dijit.byId("feedTree");
if (tree) tree.reload();
dialog.hide();
});
}
} else {
alert(__("No feeds selected."));
}
},
content: `
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span>${__('Select')}</span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Tables.select('inactive-feeds-list', true)"
dojoType='dijit.MenuItem'>${__('All')}</div>
<div onclick="Tables.select('inactive-feeds-list', false)"
dojoType='dijit.MenuItem'>${__('None')}</div>
</div>
</div>
</div>
<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'>
<input onclick='Tables.onRowChecked(this)' dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td>
<a href='#' "title="${__("Click to edit feed")}" onclick="CommonDialogs.editFeed(${row.id})">
${App.escapeHtml(row.title)}
</a>
</td>
<td class='text-muted' align='right'>
${row.last_article}
</td>
</tr>
`).join("")}
</table>
</div>
<footer>
<button style='float : left' class='alt-danger' dojoType='dijit.form.Button' onclick='App.dialogOf(this).removeSelected()'>
${__('Unsubscribe from selected feeds')}
</button>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit'>
${__('Close this window')}
</button>
</footer>
`
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
dialog.show();
xhrPost("backend.php", {op: "pref-feeds", method: "inactivefeeds"}, (transport) => {
dialog.attr('content', transport.responseText);
})
});
dialog.show();
}
});
});

@ -1,5 +1,5 @@
/* eslint-disable prefer-rest-params */
/* global __, define, lib, dijit, dojo, xhrPost, Notify */
/* global __, define, lib, dijit, dojo, xhr, App, Notify */
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], function (declare, domConstruct) {
@ -80,26 +80,26 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function (item) {
items.forEach(function (item) {
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
return rv;
},
reload: function() {
const user_search = $("filter_search");
const user_search = App.byId("filter_search");
let search = "";
if (user_search) { search = user_search.value; }
xhrPost("backend.php", { op: "pref-filters", search: search }, (transport) => {
dijit.byId('filtersTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-filters", search: search }, (reply) => {
dijit.byId('filtersTab').attr('content', reply);
Notify.close();
});
},
resetFilterOrder: function() {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-filters", method: "filtersortreset"}, () => {
xhr.post("backend.php", {op: "pref-filters", method: "filtersortreset"}, () => {
this.reload();
});
},
@ -114,28 +114,11 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
if (confirm(__("Combine selected filters?"))) {
Notify.progress("Joining filters...");
xhrPost("backend.php", {op: "pref-filters", method: "join", ids: rows.toString()}, () => {
xhr.post("backend.php", {op: "pref-filters", method: "join", ids: rows.toString()}, () => {
this.reload();
});
}
},
editSelectedFilter: function() {
const rows = this.getSelectedFilters();
if (rows.length == 0) {
alert(__("No filters selected."));
return;
}
if (rows.length > 1) {
alert(__("Please select only one filter."));
return;
}
Notify.close();
this.editFilter(rows[0]);
},
removeSelectedFilters: function() {
const sel_rows = this.getSelectedFilters();
@ -148,7 +131,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}

@ -1,7 +1,7 @@
'use strict';
/* eslint-disable no-new */
/* global __, dijit, dojo, Tables, xhrPost, Notify, xhrJson, App, fox, Effect */
/* global __, dijit, dojo, Tables, xhrPost, Notify, xhr, App, fox */
const Helpers = {
AppPasswords: {
@ -9,7 +9,7 @@ const Helpers = {
return Tables.getSelected("app-password-list");
},
updateContent: function(data) {
$("app_passwords_holder").innerHTML = data;
App.byId("app_passwords_holder").innerHTML = data;
dojo.parser.parse("app_passwords_holder");
},
removeSelected: function() {
@ -19,8 +19,8 @@ const Helpers = {
alert("No passwords selected.");
} else if (confirm(__("Remove selected app passwords?"))) {
xhrPost("backend.php", {op: "pref-prefs", method: "deleteAppPassword", ids: rows.toString()}, (transport) => {
this.updateContent(transport.responseText);
xhr.post("backend.php", {op: "pref-prefs", method: "deleteAppPassword", ids: rows.toString()}, (reply) => {
this.updateContent(reply);
Notify.close();
});
@ -31,8 +31,8 @@ const Helpers = {
const title = prompt("Password description:")
if (title) {
xhrPost("backend.php", {op: "pref-prefs", method: "generateAppPassword", title: title}, (transport) => {
this.updateContent(transport.responseText);
xhr.post("backend.php", {op: "pref-prefs", method: "generateAppPassword", title: title}, (reply) => {
this.updateContent(reply);
Notify.close();
});
@ -40,16 +40,21 @@ const Helpers = {
}
},
},
clearFeedAccessKeys: function() {
if (confirm(__("This will invalidate all previously generated feed URLs. Continue?"))) {
Notify.progress("Clearing URLs...");
Feeds: {
clearFeedAccessKeys: function() {
if (confirm(__("This will invalidate all previously generated feed URLs. Continue?"))) {
Notify.progress("Clearing URLs...");
xhrPost("backend.php", {op: "pref-feeds", method: "clearKeys"}, () => {
Notify.info("Generated URLs cleared.");
});
}
xhr.post("backend.php", {op: "pref-feeds", method: "clearKeys"}, () => {
Notify.info("Generated URLs cleared.");
});
}
return false;
return false;
},
},
System: {
//
},
EventLog: {
log_page: 0,
@ -58,8 +63,13 @@ const Helpers = {
this.update();
},
update: function() {
xhrPost("backend.php", { op: "pref-system", severity: dijit.byId("severity").attr('value'), page: Helpers.EventLog.log_page }, (transport) => {
dijit.byId('systemTab').attr('content', transport.responseText);
xhr.post("backend.php", {
op: "pref-system",
severity: dijit.byId("severity").attr('value'),
page: Helpers.EventLog.log_page
}, (reply) => {
dijit.byId('systemTab').attr('content', reply);
Notify.close();
});
},
@ -77,161 +87,216 @@ const Helpers = {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-system", method: "clearLog"}, () => {
xhr.post("backend.php", {op: "pref-system", method: "clearLog"}, () => {
Helpers.EventLog.refresh();
});
}
},
},
editProfiles: function() {
const dialog = new fox.SingleUseDialog({
id: "profileEditDlg",
title: __("Settings Profiles"),
getSelectedProfiles: function () {
return Tables.getSelected("pref-profiles-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedProfiles();
if (sel_rows.length > 0) {
if (confirm(__("Remove selected profiles? Active and default profiles will not be removed."))) {
Notify.progress("Removing selected profiles...", true);
const query = {
op: "rpc", method: "remprofiles",
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
Profiles: {
edit: function() {
const dialog = new fox.SingleUseDialog({
id: "profileEditDlg",
title: __("Settings Profiles"),
getSelectedProfiles: function () {
return Tables.getSelected("pref-profiles-list");
},
removeSelected: function () {
const sel_rows = this.getSelectedProfiles();
if (sel_rows.length > 0) {
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, () => {
Notify.close();
dialog.refresh();
});
}
} else {
alert(__("No profiles selected."));
}
},
addProfile: function () {
if (this.validate()) {
Notify.progress("Creating profile...", true);
const query = {op: "pref-prefs", method: "addprofile", title: dialog.attr('value').newprofile};
xhr.post("backend.php", query, () => {
Notify.close();
dialog.refresh();
});
}
} else {
alert(__("No profiles selected."));
}
},
addProfile: function () {
if (this.validate()) {
Notify.progress("Creating profile...", true);
const query = {op: "rpc", method: "addprofile", title: dialog.attr('value').newprofile};
}
},
refresh: function() {
xhr.json("backend.php", {op: 'pref-prefs', method: 'getprofiles'}, (reply) => {
dialog.attr('content', `
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span>${__('Select')}</span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Tables.select('pref-profiles-list', true)"
dojoType='dijit.MenuItem'>${__('All')}</div>
<div onclick="Tables.select('pref-profiles-list', false)"
dojoType='dijit.MenuItem'>${__('None')}</div>
</div>
</div>
<div class="pull-right">
<input name='newprofile' dojoType='dijit.form.ValidationTextBox' required='1'>
${App.FormFields.button_tag(__('Create profile'), "", {onclick: 'App.dialogOf(this).addProfile()'})}
</div>
</div>
xhrPost("backend.php", query, () => {
Notify.close();
dialog.refresh();
<form onsubmit='return false'>
<div class='panel panel-scrollable'>
<table width='100%' id='pref-profiles-list'>
${reply.map((profile) => `
<tr data-row-id="${profile.id}">
<td width='5%'>
${App.FormFields.checkbox_tag("", false, "", {onclick: 'Tables.onRowChecked(this)'})}
</td>
<td>
${profile.id > 0 ?
`<span dojoType='dijit.InlineEditBox' width='300px' autoSave='false'
profile-id='${profile.id}'>${profile.title}
<script type='dojo/method' event='onChange' args='value'>
xhr.post("backend.php",
{op: 'pref-prefs', method: 'saveprofile', value: value, id: this.attr('profile-id')}, () => {
//
});
</script>
</span>` : `${profile.title}`}
${profile.active ? __("(active)") : ""}
</td>
</tr>
`).join("")}
</table>
</div>
<footer>
${App.FormFields.button_tag(__('Remove selected profiles'), "",
{class: 'pull-left alt-danger', onclick: 'App.dialogOf(this).removeSelected()'})}
${App.FormFields.submit_tag(__('Activate profile'), {onclick: 'App.dialogOf(this).execute()'})}
${App.FormFields.cancel_dialog_tag(__('Cancel'))}
</footer>
</form>
`);
});
},
execute: function () {
const sel_rows = this.getSelectedProfiles();
}
},
refresh: function() {
xhrPost("backend.php", {op: 'pref-prefs', method: 'editPrefProfiles'}, (transport) => {
dialog.attr('content', transport.responseText);
});
},
execute: function () {
const sel_rows = this.getSelectedProfiles();
if (sel_rows.length == 1) {
if (confirm(__("Activate selected profile?"))) {
Notify.progress("Loading, please wait...");
if (sel_rows.length == 1) {
if (confirm(__("Activate selected profile?"))) {
Notify.progress("Loading, please wait...");
xhr.post("backend.php", {op: "pref-prefs", method: "activateprofile", id: sel_rows.toString()}, () => {
window.location.reload();
});
}
xhrPost("backend.php", {op: "rpc", method: "setprofile", id: sel_rows.toString()}, () => {
window.location.reload();
});
} else {
alert(__("Please choose a profile to activate."));
}
},
content: ""
});
} else {
alert(__("Please choose a profile to activate."));
}
},
content: ""
});
dialog.refresh();
dialog.show();
dialog.refresh();
dialog.show();
},
},
customizeCSS: function() {
xhrJson("backend.php", {op: "pref-prefs", method: "customizeCSS"}, (reply) => {
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() {
xhrPost("backend.php", this.attr('value'), () => {
new Effect.Appear("css_edit_apply_msg");
$("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>
xhrPost("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('op', 'rpc')}
${App.FormFields.hidden('method', 'setpref')}
${App.FormFields.hidden('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.")}
${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>
</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>
`
});
dialog.show();
<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>
`
});
dialog.show();
});
},
confirmReset: function() {
if (confirm(__("Reset to defaults?"))) {
xhrPost("backend.php", {op: "pref-prefs", method: "resetconfig"}, (transport) => {
Helpers.refresh();
Notify.info(transport.responseText);
});
}
},
clearPluginData: function(name) {
if (confirm(__("Clear stored data for this plugin?"))) {
Notify.progress("Loading, please wait...");
},
confirmReset: function() {
if (confirm(__("Reset to defaults?"))) {
xhr.post("backend.php", {op: "pref-prefs", method: "resetconfig"}, (reply) => {
Helpers.Prefs.refresh();
Notify.info(reply);
});
}
},
clearPluginData: function(name) {
if (confirm(__("Clear stored data for this plugin?"))) {
Notify.progress("Loading, please wait...");
xhrPost("backend.php", {op: "pref-prefs", method: "clearplugindata", name: name}, () => {
Helpers.refresh();
xhr.post("backend.php", {op: "pref-prefs", method: "clearplugindata", name: name}, () => {
Helpers.Prefs.refresh();
});
}
},
refresh: function() {
xhr.post("backend.php", { op: "pref-prefs" }, (reply) => {
dijit.byId('prefsTab').attr('content', reply);
Notify.close();
});
}
},
refresh: function() {
xhrPost("backend.php", { op: "pref-prefs" }, (transport) => {
dijit.byId('prefsTab').attr('content', transport.responseText);
Notify.close();
});
},
},
OPML: {
import: function() {
const opml_file = $("opml_file");
const opml_file = App.byId("opml_file");
if (opml_file.value.length == 0) {
alert(__("Please choose an OPML file first."));
@ -273,7 +338,7 @@ const Helpers = {
dialog.show();
};
xhr.send(new FormData($("opml_import_form")));
xhr.send(new FormData(App.byId("opml_import_form")));
return false;
}
@ -282,30 +347,62 @@ const Helpers = {
console.log("export");
window.open("backend.php?op=opml&method=export&" + dojo.formToQuery("opmlExportForm"));
},
changeKey: function() {
if (confirm(__("Replace current OPML publishing address with a new one?"))) {
Notify.progress("Trying to change address...", true);
xhrJson("backend.php", {op: "pref-feeds", method: "regenOPMLKey"}, (reply) => {
if (reply) {
const new_link = reply.link;
const e = $('pub_opml_url');
publish: function() {
Notify.progress("Loading, please wait...", true);
if (new_link) {
e.href = new_link;
e.innerHTML = new_link;
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>
`
});
new Effect.Highlight(e);
dialog.show();
Notify.close();
Notify.close();
} else {
Notify.error("Could not change feed URL.");
}
}
});
}
return false;
} catch (e) {
App.Error.report(e);
}
});
},
}
};

@ -1,5 +1,5 @@
/* eslint-disable prefer-rest-params */
/* global __, define, lib, dijit, dojo, xhrPost, Notify, fox */
/* global __, define, lib, dijit, dojo, xhr, Notify, fox, App */
define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/form/DropDownButton"], function (declare, domConstruct) {
@ -48,83 +48,140 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
const items = tree.model.getCheckedItems();
const rv = [];
items.each(function(item) {
items.forEach(function(item) {
rv.push(tree.model.store.getValue(item, 'bare_id'));
});
return rv;
},
reload: function() {
xhrPost("backend.php", { op: "pref-labels" }, (transport) => {
dijit.byId('labelsTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-labels" }, (reply) => {
dijit.byId('labelsTab').attr('content', reply);
Notify.close();
});
},
editLabel: function(id) {
const dialog = new fox.SingleUseDialog({
id: "labelEditDlg",
title: __("Label Editor"),
style: "width: 650px",
setLabelColor: function (id, fg, bg) {
let kind = '';
let color = '';
if (fg && bg) {
kind = 'both';
} else if (fg) {
kind = 'fg';
color = fg;
} else if (bg) {
kind = 'bg';
color = bg;
}
const e = $("icon-label-" + id);
if (e) {
if (bg) e.style.color = bg;
}
const query = {
op: "pref-labels", method: "colorset", kind: kind,
ids: id, fg: fg, bg: bg, color: color
};
xhrPost("backend.php", query, () => {
const tree = dijit.byId("filterTree");
if (tree) tree.reload(); // maybe there's labels in there
});
},
execute: function () {
if (this.validate()) {
const caption = this.attr('value').caption;
const fg_color = this.attr('value').fg_color;
const bg_color = this.attr('value').bg_color;
dijit.byId('labelTree').setNameById(id, caption);
this.setLabelColor(id, fg_color, bg_color);
this.hide();
xhrPost("backend.php", this.attr('value'), () => {
xhr.json("backend.php", {op: "pref-labels", method: "edit", id: id}, (reply) => {
const fg_color = reply['fg_color'];
const bg_color = reply['bg_color'] ? reply['bg_color'] : '#fff7d5';
const dialog = new fox.SingleUseDialog({
id: "labelEditDlg",
title: __("Label Editor"),
style: "width: 650px",
setLabelColor: function (id, fg, bg) {
let kind = '';
let color = '';
if (fg && bg) {
kind = 'both';
} else if (fg) {
kind = 'fg';
color = fg;
} else if (bg) {
kind = 'bg';
color = bg;
}
const e = App.byId(`icon-label-${id}`);
if (e) {
if (bg) e.style.color = bg;
}
const query = {
op: "pref-labels", method: "colorset", kind: kind,
ids: id, fg: fg, bg: bg, color: color
};
xhr.post("backend.php", query, () => {
const tree = dijit.byId("filterTree");
if (tree) tree.reload(); // maybe there's labels in there
});
}
},
content: __("Loading, please wait...")
});
const tmph = dojo.connect(dialog, 'onShow', function () {
dojo.disconnect(tmph);
},
execute: function () {
if (this.validate()) {
const caption = this.attr('value').caption;
const fg_color = this.attr('value').fg_color;
const bg_color = this.attr('value').bg_color;
dijit.byId('labelTree').setNameById(id, caption);
this.setLabelColor(id, fg_color, bg_color);
this.hide();
xhr.post("backend.php", this.attr('value'), () => {
const tree = dijit.byId("filterTree");
if (tree) tree.reload(); // maybe there's labels in there
});
}
},
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'
id='labelEdit_caption'
name='caption'
dojoType='dijit.form.ValidationTextBox'
required='true'
value="${App.escapeHtml(reply.caption)}">
</section>
${App.FormFields.hidden_tag('id', id)}
${App.FormFields.hidden_tag('op', 'pref-labels')}
${App.FormFields.hidden_tag('method', 'save')}
${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>
<th>${__("Foreground:")}</th>
<th>${__("Background:")}</th>
</tr>
<tr>
<td class='text-center'>
<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='fg_color'>
dijit.byId('labelEdit_fgColor').attr('value', fg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({color: fg_color});
</script>
</div>
</td>
<td class='text-center'>
<div dojoType='dijit.ColorPalette'>
<script type='dojo/method' event='onChange' args='bg_color'>
dijit.byId('labelEdit_bgColor').attr('value', bg_color);
dijit.byId('labelEdit_caption').domNode.setStyle({backgroundColor: bg_color});
</script>
</div>
</td>
</tr>
</table>
</section>
<footer>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary' onclick='App.dialogOf(this).execute()'>
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
</form>
`
});
xhrPost("backend.php", {op: "pref-labels", method: "edit", id: id}, (transport) => {
dialog.attr('content', transport.responseText);
})
});
dialog.show();
dialog.show();
});
},
resetColors: function() {
const labels = this.getSelectedLabels();
@ -137,7 +194,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
ids: labels.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -158,7 +215,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree", "dijit/f
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}

@ -1,15 +1,15 @@
'use strict'
/* global __ */
/* global xhrPost, dojo, dijit, Notify, Tables, fox */
/* global xhrPost, xhr, dijit, Notify, Tables, App, fox */
const Users = {
reload: function(sort) {
const user_search = $("user_search");
const user_search = App.byId("user_search");
const search = user_search ? user_search.value : "";
xhrPost("backend.php", { op: "pref-users", sort: sort, search: search }, (transport) => {
dijit.byId('usersTab').attr('content', transport.responseText);
xhr.post("backend.php", { op: "pref-users", sort: sort, search: search }, (reply) => {
dijit.byId('usersTab').attr('content', reply);
Notify.close();
});
},
@ -19,15 +19,18 @@ const Users = {
if (login) {
Notify.progress("Adding user...");
xhrPost("backend.php", {op: "pref-users", method: "add", login: login}, (transport) => {
alert(transport.responseText);
xhr.post("backend.php", {op: "pref-users", method: "add", login: login}, (reply) => {
alert(reply);
Users.reload();
});
}
},
edit: function(id) {
xhrPost('backend.php', {op: 'pref-users', method: 'edit', id: id}, (transport) => {
xhr.json('backend.php', {op: 'pref-users', method: 'edit', id: id}, (reply) => {
const user = reply.user;
const admin_disabled = (user.id == 1);
const dialog = new fox.SingleUseDialog({
id: "userEditDlg",
title: __("User Editor"),
@ -35,13 +38,86 @@ const Users = {
if (this.validate()) {
Notify.progress("Saving data...", true);
xhrPost("backend.php", dojo.formToObject("user_edit_form"), (/* transport */) => {
xhr.post("backend.php", this.attr('value'), () => {
dialog.hide();
Users.reload();
});
}
},
content: transport.responseText
content: `
<form onsubmit='return false'>
${App.FormFields.hidden_tag('id', user.id.toString())}
${App.FormFields.hidden_tag('op', 'pref-users')}
${App.FormFields.hidden_tag('method', 'editSave')}
<div dojoType="dijit.layout.TabContainer" style="height : 400px">
<div dojoType="dijit.layout.ContentPane" title="${__('Edit user')}">
<header>${__("User")}</header>
<section>
<fieldset>
<label>${__("Login:")}</label>
<input style='font-size : 16px'
${admin_disabled ? "disabled='1'" : ''}
dojoType='dijit.form.ValidationTextBox' required='1'
name='login' value="${App.escapeHtml(user.login)}">
${admin_disabled ? App.FormFields.hidden_tag("login", user.login) : ''}
</fieldset>
</section>
<header>${__("Authentication")}</header>
<section>
<fieldset>
<label>${__('Access level: ')}</label>
${App.FormFields.select_hash("access_level",
user.access_level, reply.access_level_names, {disabled: admin_disabled.toString()})}
${admin_disabled ? App.FormFields.hidden_tag("access_level",
user.access_level.toString()) : ''}
</fieldset>
<fieldset>
<label>${__("New password:")}</label>
<input dojoType='dijit.form.TextBox' type='password' size='20'
placeholder='${__("Change password")}' name='password'>
</fieldset>
</section>
<header>${__("Options")}</header>
<section>
<fieldset>
<label>${__("E-mail:")}</label>
<input dojoType='dijit.form.TextBox' size='30' name='email'
value="${App.escapeHtml(user.email)}">
</fieldset>
</section>
</div>
<div dojoType="dijit.layout.ContentPane" title="${__('User details')}">
<script type='dojo/method' event='onShow' args='evt'>
if (this.domNode.querySelector('.loading')) {
xhr.post("backend.php", {op: 'pref-users', method: 'userdetails', id: ${user.id}}, (reply) => {
this.attr('content', reply);
});
}
</script>
<span class='loading'>${__("Loading, please wait...")}</span>
</div>
</div>
<footer>
<button dojoType='dijit.form.Button' class='alt-primary' type='submit' onclick='App.dialogOf(this).execute()'>
${__('Save')}
</button>
<button dojoType='dijit.form.Button' onclick='App.dialogOf(this).hide()'>
${__('Cancel')}
</button>
</footer>
</form>
`
});
dialog.show();
@ -65,9 +141,9 @@ const Users = {
const id = rows[0];
xhrPost("backend.php", {op: "pref-users", method: "resetPass", id: id}, (transport) => {
xhr.post("backend.php", {op: "pref-users", method: "resetPass", id: id}, (reply) => {
Notify.close();
Notify.info(transport.responseText, true);
Notify.info(reply, true);
});
}
@ -84,7 +160,7 @@ const Users = {
ids: sel_rows.toString()
};
xhrPost("backend.php", query, () => {
xhr.post("backend.php", query, () => {
this.reload();
});
}
@ -93,21 +169,6 @@ const Users = {
alert(__("No users selected."));
}
},
editSelected: function() {
const rows = this.getSelection();
if (rows.length == 0) {
alert(__("No users selected."));
return;
}
if (rows.length > 1) {
alert(__("Please select one user."));
return;
}
this.edit(rows[0]);
},
getSelection :function() {
return Tables.getSelected("users-list");
}

@ -1,6 +1,17 @@
/* eslint-disable prefer-rest-params */
/* global dijit, define */
define(["dojo/_base/declare", "dijit/Dialog"], function (declare) {
return declare("fox.SingleUseDialog", dijit.Dialog, {
create: function(params) {
const extant = dijit.byId(params.id);
if (extant) {
console.warn('SingleUseDialog: destroying existing widget:', params.id, '=', extant)
extant.destroyRecursive();
}
return this.inherited(arguments);
},
onHide: function() {
this.destroyRecursive();
}

@ -1,60 +1,225 @@
'use strict';
/* global dijit, __, App, Ajax */
/* global dijit, __, App, dojo, __csrf_token */
/* eslint-disable no-new */
/* error reporting shim */
// TODO: deprecated; remove
/* function exception_error(e, e_compat, filename, lineno, colno) {
if (typeof e == "string")
e = e_compat;
/* exported $ */
function $(id) {
console.warn("FIXME: please use App.byId() or document.getElementById() instead of $():", id);
return document.getElementById(id);
}
App.Error.report(e, {filename: filename, lineno: lineno, colno: colno});
} */
/* exported $$ */
function $$(query) {
console.warn("FIXME: please use App.findAll() or document.querySelectorAll() instead of $$():", query);
return document.querySelectorAll(query);
}
/* xhr shorthand helpers */
/* exported xhrPost */
function xhrPost(url, params, complete) {
console.log("xhrPost:", params);
Element.prototype.hasClassName = function(className) {
return this.classList.contains(className);
};
return new Promise((resolve, reject) => {
new Ajax.Request(url, {
parameters: params,
onComplete: function(reply) {
if (complete != undefined) complete(reply);
Element.prototype.addClassName = function(className) {
return this.classList.add(className);
};
resolve(reply);
}
});
Element.prototype.removeClassName = function(className) {
return this.classList.remove(className);
};
Element.prototype.toggleClassName = function(className) {
if (this.hasClassName(className))
return this.removeClassName(className);
else
return this.addClassName(className);
};
Element.prototype.setStyle = function(args) {
Object.keys(args).forEach((k) => {
this.style[k] = args[k];
});
};
Element.prototype.show = function() {
this.style.display = "";
};
Element.prototype.hide = function() {
this.style.display = "none";
};
Element.prototype.toggle = function() {
if (this.visible())
this.hide();
else
this.show();
};
// https://gist.github.com/alirezas/c4f9f43e9fe1abba9a4824dd6fc60a55
Element.prototype.fadeOut = function() {
this.style.opacity = 1;
const self = this;
(function fade() {
if ((self.style.opacity -= 0.1) < 0) {
self.style.display = "none";
} else {
requestAnimationFrame(fade);
}
}());
};
Element.prototype.fadeIn = function(display = undefined){
this.style.opacity = 0;
this.style.display = display == undefined ? "block" : display;
const self = this;
(function fade() {
let val = parseFloat(self.style.opacity);
if (!((val += 0.1) > 1)) {
self.style.opacity = val;
requestAnimationFrame(fade);
}
}());
};
Element.prototype.visible = function() {
return this.style.display != "none" && this.offsetHeight != 0 && this.offsetWidth != 0;
}
/* exported xhrJson */
function xhrJson(url, params, complete) {
return new Promise((resolve, reject) =>
xhrPost(url, params).then((reply) => {
let obj = null;
try {
obj = JSON.parse(reply.responseText);
} catch (e) {
console.error("xhrJson", e, reply);
}
Element.visible = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.visible();
}
Element.show = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.show();
}
if (complete != undefined) complete(obj);
Element.hide = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
resolve(obj);
}));
return elem.hide();
}
Element.toggle = function(elem) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.toggle();
}
Element.hasClassName = function (elem, className) {
if (typeof elem == "string")
elem = document.getElementById(elem);
return elem.hasClassName(className);
}
/* add method to remove element from array */
Array.prototype.remove = function(s) {
for (let i=0; i < this.length; i++) {
if (s == this[i]) this.splice(i, 1);
}
};
Array.prototype.uniq = function() {
return this.filter((v, i, a) => a.indexOf(v) === i);
};
String.prototype.stripTags = function() {
return this.replace(/<\w+(\s+("[^"]*"|'[^']*'|[^>])+)?(\/)?>|<\/\w+>/gi, '');
}
/* exported xhr */
const xhr = {
post: function(url, params = {}, complete = undefined) {
console.log('xhr.post', '>>>', params);
return new Promise((resolve, reject) => {
if (typeof __csrf_token != "undefined")
params = {...params, ...{csrf_token: __csrf_token}};
dojo.xhrPost({url: url,
postData: dojo.objectToQuery(params),
handleAs: "text",
error: function(error) {
reject(error);
},
load: function(data, ioargs) {
console.log('xhr.post', '<<<', ioargs.xhr);
if (complete != undefined)
complete(data, ioargs.xhr);
resolve(data)
}}
);
});
},
json: function(url, params = {}, complete = undefined) {
return new Promise((resolve, reject) =>
this.post(url, params).then((data) => {
let obj = null;
try {
obj = JSON.parse(data);
} catch (e) {
console.error("xhr.json", e, xhr);
reject(e);
}
console.log('xhr.json', '<<<', obj);
if (obj && typeof App != "undefined")
if (!App.handleRpcJson(obj)) {
reject(obj);
return;
}
if (complete != undefined) complete(obj);
resolve(obj);
}
));
}
};
/* exported xhrPost */
function xhrPost(url, params = {}, complete = undefined) {
console.log("xhrPost:", params);
return new Promise((resolve, reject) => {
if (typeof __csrf_token != "undefined")
params = {...params, ...{csrf_token: __csrf_token}};
dojo.xhrPost({url: url,
postData: dojo.objectToQuery(params),
handleAs: "text",
error: function(error) {
reject(error);
},
load: function(data, ioargs) {
if (complete != undefined)
complete(ioargs.xhr);
resolve(ioargs.xhr)
}});
});
}
/* exported xhrJson */
function xhrJson(url, params = {}, complete = undefined) {
return xhr.json(url, params, complete);
}
/* common helpers not worthy of separate Dojo modules */
/* exported Lists */
@ -64,14 +229,14 @@ const Lists = {
// account for dojo checkboxes
elem = elem.domNode || elem;
const row = elem.up("li");
const row = elem.closest("li");
if (row)
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).select("li").each((row) => {
const checkNode = row.select(".dijitCheckBox,input[type=checkbox]")[0];
$(elemId).querySelectorAll("li").forEach((row) => {
const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]");
if (checkNode) {
const widget = dijit.getEnclosingWidget(checkNode);
@ -94,15 +259,15 @@ const Tables = {
const checked = elem.domNode ? elem.attr("checked") : elem.checked;
elem = elem.domNode || elem;
const row = elem.up("tr");
const row = elem.closest("tr");
if (row)
checked ? row.addClassName("Selected") : row.removeClassName("Selected");
},
select: function(elemId, selected) {
$(elemId).select("tr").each((row) => {
const checkNode = row.select(".dijitCheckBox,input[type=checkbox]")[0];
$(elemId).querySelectorAll("tr").forEach((row) => {
const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]");
if (checkNode) {
const widget = dijit.getEnclosingWidget(checkNode);
@ -119,7 +284,7 @@ const Tables = {
getSelected: function(elemId) {
const rv = [];
$(elemId).select("tr").each((row) => {
$(elemId).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]*?-/, "");
@ -173,7 +338,7 @@ const Notify = {
kind = kind || this.KIND_GENERIC;
keep = keep || false;
const notify = $("notify");
const notify = App.byId("notify");
window.clearTimeout(this.timeout);
@ -238,25 +403,3 @@ const Notify = {
}
};
// http://stackoverflow.com/questions/6251937/how-to-get-selecteduser-highlighted-text-in-contenteditable-element-and-replac
/* exported getSelectionText */
function getSelectionText() {
let text = "";
if (typeof window.getSelection != "undefined") {
const sel = window.getSelection();
if (sel.rangeCount) {
const container = document.createElement("div");
for (let i = 0, len = sel.rangeCount; i < len; ++i) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
text = container.innerHTML;
}
} else if (typeof document.selection != "undefined") {
if (document.selection.type == "Text") {
text = document.selection.createRange().textText;
}
}
return text.stripTags();
}

@ -0,0 +1,20 @@
/* global define */
// only supports required for the time being
// TODO: maybe show dojo native error message? i dunno
define(["dojo/_base/declare", "dojo/_base/lang", "dijit/form/MultiSelect", ],
function(declare, lang, MultiSelect) {
return declare('fox.form.ValidationMultiSelect', [MultiSelect], {
constructor: function(params){
this.constraints = {};
this.baseClass += ' dijitValidationMultiSelect';
},
validate: function(/*Boolean*/ isFocused){
if (this.required && this.attr('value').length == 0)
return false;
return true;
},
})
});

@ -41,6 +41,7 @@ require(["dojo/_base/kernel",
"dojo/data/ItemFileWriteStore",
"lib/CheckBoxStoreModel",
"lib/CheckBoxTree",
"fox/PluginHost",
"fox/CommonDialogs",
"fox/CommonFilters",
"fox/PrefUsers",
@ -52,6 +53,7 @@ require(["dojo/_base/kernel",
"fox/PrefLabelTree",
"fox/Toolbar",
"fox/SingleUseDialog",
"fox/form/ValidationMultiSelect",
"fox/form/ValidationTextArea",
"fox/form/Select",
"fox/form/ComboButton",

@ -1,6 +1,6 @@
'use strict'
/* global require, App, $H */
/* global require, App, dojo */
/* exported Plugins */
const Plugins = {};
@ -51,6 +51,7 @@ require(["dojo/_base/kernel",
"fox/FeedTree",
"fox/Toolbar",
"fox/SingleUseDialog",
"fox/form/ValidationMultiSelect",
"fox/form/ValidationTextArea",
"fox/form/Select",
"fox/form/ComboButton",
@ -70,13 +71,13 @@ require(["dojo/_base/kernel",
/* exported hash_get */
function hash_get(key) {
const kv = window.location.hash.substring(1).toQueryParams();
return kv[key];
const obj = dojo.queryToObject(window.location.hash.substring(1));
return obj[key];
}
/* exported hash_set */
function hash_set(key, value) {
const kv = window.location.hash.substring(1).toQueryParams();
kv[key] = value;
window.location.hash = $H(kv).toQueryString();
const obj = dojo.queryToObject(window.location.hash.substring(1));
obj[key] = value;
window.location.hash = dojo.objectToQuery(obj);
}

@ -2,7 +2,7 @@
/* TODO: this should probably be something like night_mode.js since it does nothing specific to utility scripts */
Event.observe(window, "load", function() {
window.addEventListener("load", function() {
const UtilityJS = {
apply_night_mode: function (is_night, link) {
console.log("night mode changed to", is_night);
@ -16,10 +16,10 @@ Event.observe(window, "load", function() {
setup_night_mode: function() {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const link = new Element("link", {
rel: "stylesheet",
id: "theme_auto_css"
});
const link = document.createElement("link");
link.rel = "stylesheet";
link.id = "theme_auto_css";
link.onload = function() {
document.querySelector("body").removeClassName("css_loading");

@ -1,186 +0,0 @@
<?php
/*
* accept-to-gettext.inc -- convert information in 'Accept-*' headers to
* gettext language identifiers.
* Copyright (c) 2003, Wouter Verhelst <wouter@debian.org>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*
* Usage:
*
* $locale=al2gt(<array of supported languages/charsets in gettext syntax>,
* <MIME type of document>);
* setlocale('LC_ALL', $locale); // or 'LC_MESSAGES', or whatever...
*
* Example:
*
* $langs=array('nl_BE.ISO-8859-15','nl_BE.UTF-8','en_US.UTF-8','en_GB.UTF-8');
* $locale=al2gt($langs, 'text/html');
* setlocale('LC_ALL', $locale);
*
* Note that this will send out header information (to be
* RFC2616-compliant), so it must be called before anything is sent to
* the user.
*
* Assumptions made:
* * Charset encodings are written the same way as the Accept-Charset
* HTTP header specifies them (RFC2616), except that they're parsed
* case-insensitive.
* * Country codes and language codes are the same in both gettext and
* the Accept-Language syntax (except for the case differences, which
* are dealt with easily). If not, some input may be ignored.
* * The provided gettext-strings are fully qualified; i.e., no "en_US";
* always "en_US.ISO-8859-15" or "en_US.UTF-8", or whichever has been
* used. "en.ISO-8859-15" is OK, though.
* * The language is more important than the charset; i.e., if the
* following is given:
*
* Accept-Language: nl-be, nl;q=0.8, en-us;q=0.5, en;q=0.3
* Accept-Charset: ISO-8859-15, utf-8;q=0.5
*
* And the supplied parameter contains (amongst others) nl_BE.UTF-8
* and nl.ISO-8859-15, then nl_BE.UTF-8 will be picked.
*
* $Log: accept-to-gettext.inc,v $
* Revision 1.1.1.1 2003/11/19 19:31:15 wouter
* * moved to new CVS repo after death of the old
* * Fixed code to apply a default to both Accept-Charset and
* Accept-Language if none of those headers are supplied; patch from
* Dominic Chambers <dominic@encasa.com>
*
* Revision 1.2 2003/08/14 10:23:59 wouter
* Removed little error in Content-Type header syntaxis.
*
* 2007-04-01
* add '@' before use of arrays, to avoid PHP warnings.
*/
/* not really important, this one; perhaps I could've put it inline with
* the rest. */
function find_match($curlscore,$curcscore,$curgtlang,$langval,$charval,
$gtlang)
{
if($curlscore < $langval) {
$curlscore=$langval;
$curcscore=$charval;
$curgtlang=$gtlang;
} else if ($curlscore == $langval) {
if($curcscore < $charval) {
$curcscore=$charval;
$curgtlang=$gtlang;
}
}
return array($curlscore, $curcscore, $curgtlang);
}
function al2gt($gettextlangs, $mime) {
/* default to "everything is acceptable", as RFC2616 specifies */
$acceptLang=(($_SERVER["HTTP_ACCEPT_LANGUAGE"] == '') ? '*' :
$_SERVER["HTTP_ACCEPT_LANGUAGE"]);
$acceptChar=(($_SERVER["HTTP_ACCEPT_CHARSET"] == '') ? '*' :
$_SERVER["HTTP_ACCEPT_CHARSET"]);
$alparts=@preg_split("/,/",$acceptLang);
$acparts=@preg_split("/,/",$acceptChar);
/* Parse the contents of the Accept-Language header.*/
foreach($alparts as $part) {
$part=trim($part);
if(preg_match("/;/", $part)) {
$lang=@preg_split("/;/",$part);
$score=@preg_split("/=/",$lang[1]);
$alscores[$lang[0]]=$score[1];
} else {
$alscores[$part]=1;
}
}
/* Do the same for the Accept-Charset header. */
/* RFC2616: ``If no "*" is present in an Accept-Charset field, then
* all character sets not explicitly mentioned get a quality value of
* 0, except for ISO-8859-1, which gets a quality value of 1 if not
* explicitly mentioned.''
*
* Making it 2 for the time being, so that we
* can distinguish between "not specified" and "specified as 1" later
* on. */
$acscores["ISO-8859-1"]=2;
foreach($acparts as $part) {
$part=trim($part);
if(preg_match("/;/", $part)) {
$cs=@preg_split("/;/",$part);
$score=@preg_split("/=/",$cs[1]);
$acscores[strtoupper($cs[0])]=$score[1];
} else {
$acscores[strtoupper($part)]=1;
}
}
if($acscores["ISO-8859-1"]==2) {
$acscores["ISO-8859-1"]=(isset($acscores["*"])?$acscores["*"]:1);
}
/*
* Loop through the available languages/encodings, and pick the one
* with the highest score, excluding the ones with a charset the user
* did not include.
*/
$curlscore=0;
$curcscore=0;
$curgtlang=NULL;
foreach($gettextlangs as $gtlang) {
$tmp1=preg_replace("/\_/","-",$gtlang);
$tmp2=@preg_split("/\./",$tmp1);
$allang=strtolower($tmp2[0]);
$gtcs=strtoupper($tmp2[1]);
$noct=@preg_split("/-/",$allang);
$testvals=array(
array(@$alscores[$allang], @$acscores[$gtcs]),
array(@$alscores[$noct[0]], @$acscores[$gtcs]),
array(@$alscores[$allang], @$acscores["*"]),
array(@$alscores[$noct[0]], @$acscores["*"]),
array(@$alscores["*"], @$acscores[$gtcs]),
array(@$alscores["*"], @$acscores["*"]));
$found=FALSE;
foreach($testvals as $tval) {
if(!$found && isset($tval[0]) && isset($tval[1])) {
$arr=find_match($curlscore, $curcscore, $curgtlang, $tval[0],
$tval[1], $gtlang);
$curlscore=$arr[0];
$curcscore=$arr[1];
$curgtlang=$arr[2];
$found=TRUE;
}
}
}
/* We must re-parse the gettext-string now, since we may have found it
* through a "*" qualifier.*/
$gtparts=@preg_split("/\./",$curgtlang);
$tmp=strtolower($gtparts[0]);
$lang=preg_replace("/\_/", "-", $tmp);
$charset=$gtparts[1];
header("Content-Language: $lang");
header("Content-Type: $mime; charset=$charset");
return $curgtlang;
}
?>

@ -10,6 +10,8 @@ class floIconIcon {
}
class floIcon {
public $images = array();
function readICO($file) {
$jim = new jimIcon();
$icon = new floIconIcon();

@ -104,11 +104,11 @@ class jimIcon {
}
// See if we can parse it (might be PNG format here)
$i = @imagecreatefromstring($data);
if ($i) {
imagesavealpha($i, true);
return $i;
if (self::has_parsable_image_type($data)) {
if ($i = @imagecreatefromstring($data)) {
imagesavealpha($i, true);
return $i;
}
}
// Must be a BMP. Parse it ourselves.
@ -267,5 +267,12 @@ class jimIcon {
}
return $img;
}
// Checks whether the data is a type parsable by imagecreatefromstring()
private function has_parsable_image_type($image_data) {
$size = getimagesizefromstring($image_data);
return $size && in_array($size[2],
[IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_BMP, IMAGETYPE_WBMP, IMAGETYPE_WEBP]);
}
}
?>

7590
lib/prototype.js vendored

File diff suppressed because it is too large Load Diff

@ -1,965 +0,0 @@
// script.aculo.us controls.js v1.9.0, Thu Dec 23 16:54:48 -0500 2010
// Copyright (c) 2005-2010 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
// (c) 2005-2010 Ivan Krstic (http://blogs.law.harvard.edu/ivan)
// (c) 2005-2010 Jon Tirsen (http://www.tirsen.com)
// Contributors:
// Richard Livsey
// Rahul Bhargava
// Rob Wills
//
// script.aculo.us is freely distributable under the terms of an MIT-style license.
// For details, see the script.aculo.us web site: http://script.aculo.us/
// Autocompleter.Base handles all the autocompletion functionality
// that's independent of the data source for autocompletion. This
// includes drawing the autocompletion menu, observing keyboard
// and mouse events, and similar.
//
// Specific autocompleters need to provide, at the very least,
// a getUpdatedChoices function that will be invoked every time
// the text inside the monitored textbox changes. This method
// should get the text for which to provide autocompletion by
// invoking this.getToken(), NOT by directly accessing
// this.element.value. This is to allow incremental tokenized
// autocompletion. Specific auto-completion logic (AJAX, etc)
// belongs in getUpdatedChoices.
//
// Tokenized incremental autocompletion is enabled automatically
// when an autocompleter is instantiated with the 'tokens' option
// in the options parameter, e.g.:
// new Ajax.Autocompleter('id','upd', '/url/', { tokens: ',' });
// will incrementally autocomplete with a comma as the token.
// Additionally, ',' in the above example can be replaced with
// a token array, e.g. { tokens: [',', '\n'] } which
// enables autocompletion on multiple tokens. This is most
// useful when one of the tokens is \n (a newline), as it
// allows smart autocompletion after linebreaks.
if(typeof Effect == 'undefined')
throw("controls.js requires including script.aculo.us' effects.js library");
var Autocompleter = { };
Autocompleter.Base = Class.create({
baseInitialize: function(element, update, options) {
element = $(element);
this.element = element;
this.update = $(update);
this.hasFocus = false;
this.changed = false;
this.active = false;
this.index = 0;
this.entryCount = 0;
this.oldElementValue = this.element.value;
if(this.setOptions)
this.setOptions(options);
else
this.options = options || { };
this.options.paramName = this.options.paramName || this.element.name;
this.options.tokens = this.options.tokens || [];
this.options.frequency = this.options.frequency || 0.4;
this.options.minChars = this.options.minChars || 1;
this.options.onShow = this.options.onShow ||
function(element, update){
if(!update.style.position || update.style.position=='absolute') {
update.style.position = 'absolute';
Position.clone(element, update, {
setHeight: false,
offsetTop: element.offsetHeight
});
}
Effect.Appear(update,{duration:0.15});
};
this.options.onHide = this.options.onHide ||
function(element, update){ new Effect.Fade(update,{duration:0.15}) };
if(typeof(this.options.tokens) == 'string')
this.options.tokens = new Array(this.options.tokens);
// Force carriage returns as token delimiters anyway
if (!this.options.tokens.include('\n'))
this.options.tokens.push('\n');
this.observer = null;
this.element.setAttribute('autocomplete','off');
Element.hide(this.update);
Event.observe(this.element, 'blur', this.onBlur.bindAsEventListener(this));
Event.observe(this.element, 'keydown', this.onKeyPress.bindAsEventListener(this));
},
show: function() {
if(Element.getStyle(this.update, 'display')=='none') this.options.onShow(this.element, this.update);
if(!this.iefix &&
(Prototype.Browser.IE) &&
(Element.getStyle(this.update, 'position')=='absolute')) {
new Insertion.After(this.update,
'<iframe id="' + this.update.id + '_iefix" '+
'style="display:none;position:absolute;filter:progid:DXImageTransform.Microsoft.Alpha(opacity=0);" ' +
'src="javascript:false;" frameborder="0" scrolling="no"></iframe>');
this.iefix = $(this.update.id+'_iefix');
}
if(this.iefix) setTimeout(this.fixIEOverlapping.bind(this), 50);
},
fixIEOverlapping: function() {
Position.clone(this.update, this.iefix, {setTop:(!this.update.style.height)});
this.iefix.style.zIndex = 1;
this.update.style.zIndex = 2;
Element.show(this.iefix);
},
hide: function() {
this.stopIndicator();
if(Element.getStyle(this.update, 'display')!='none') this.options.onHide(this.element, this.update);
if(this.iefix) Element.hide(this.iefix);
},
startIndicator: function() {
if(this.options.indicator) Element.show(this.options.indicator);
},
stopIndicator: function() {
if(this.options.indicator) Element.hide(this.options.indicator);
},
onKeyPress: function(event) {
if(this.active)
switch(event.keyCode) {
case Event.KEY_TAB:
case Event.KEY_RETURN:
this.selectEntry();
Event.stop(event);
case Event.KEY_ESC:
this.hide();
this.active = false;
Event.stop(event);
return;
case Event.KEY_LEFT:
case Event.KEY_RIGHT:
return;
case Event.KEY_UP:
this.markPrevious();
this.render();
Event.stop(event);
return;
case Event.KEY_DOWN:
this.markNext();
this.render();
Event.stop(event);
return;
}
else
if(event.keyCode==Event.KEY_TAB || event.keyCode==Event.KEY_RETURN ||
(Prototype.Browser.WebKit > 0 && event.keyCode == 0)) return;
this.changed = true;
this.hasFocus = true;
if(this.observer) clearTimeout(this.observer);
this.observer =
setTimeout(this.onObserverEvent.bind(this), this.options.frequency*1000);
},
activate: function() {
this.changed = false;
this.hasFocus = true;
this.getUpdatedChoices();
},
onHover: function(event) {
var element = Event.findElement(event, 'LI');
if(this.index != element.autocompleteIndex)
{
this.index = element.autocompleteIndex;
this.render();
}
Event.stop(event);
},
onClick: function(event) {
var element = Event.findElement(event, 'LI');
this.index = element.autocompleteIndex;
this.selectEntry();
this.hide();
},
onBlur: function(event) {
// needed to make click events working
setTimeout(this.hide.bind(this), 250);
this.hasFocus = false;
this.active = false;
},
render: function() {
if(this.entryCount > 0) {
for (var i = 0; i < this.entryCount; i++)
this.index==i ?
Element.addClassName(this.getEntry(i),"selected") :
Element.removeClassName(this.getEntry(i),"selected");
if(this.hasFocus) {
this.show();
this.active = true;
}
} else {
this.active = false;
this.hide();
}
},
markPrevious: function() {
if(this.index > 0) this.index--;
else this.index = this.entryCount-1;
this.getEntry(this.index).scrollIntoView(true);
},
markNext: function() {
if(this.index < this.entryCount-1) this.index++;
else this.index = 0;
this.getEntry(this.index).scrollIntoView(false);
},
getEntry: function(index) {
return this.update.firstChild.childNodes[index];
},
getCurrentEntry: function() {
return this.getEntry(this.index);
},
selectEntry: function() {
this.active = false;
this.updateElement(this.getCurrentEntry());
},
updateElement: function(selectedElement) {
if (this.options.updateElement) {
this.options.updateElement(selectedElement);
return;
}
var value = '';
if (this.options.select) {
var nodes = $(selectedElement).select('.' + this.options.select) || [];
if(nodes.length>0) value = Element.collectTextNodes(nodes[0], this.options.select);
} else
value = Element.collectTextNodesIgnoreClass(selectedElement, 'informal');
var bounds = this.getTokenBounds();
if (bounds[0] != -1) {
var newValue = this.element.value.substr(0, bounds[0]);
var whitespace = this.element.value.substr(bounds[0]).match(/^\s+/);
if (whitespace)
newValue += whitespace[0];
this.element.value = newValue + value + this.element.value.substr(bounds[1]);
} else {
this.element.value = value;
}
this.oldElementValue = this.element.value;
this.element.focus();
if (this.options.afterUpdateElement)
this.options.afterUpdateElement(this.element, selectedElement);
},
updateChoices: function(choices) {
if(!this.changed && this.hasFocus) {
this.update.innerHTML = choices;
Element.cleanWhitespace(this.update);
Element.cleanWhitespace(this.update.down());
if(this.update.firstChild && this.update.down().childNodes) {
this.entryCount =
this.update.down().childNodes.length;
for (var i = 0; i < this.entryCount; i++) {
var entry = this.getEntry(i);
entry.autocompleteIndex = i;
this.addObservers(entry);
}
} else {
this.entryCount = 0;
}
this.stopIndicator();
this.index = 0;
if(this.entryCount==1 && this.options.autoSelect) {
this.selectEntry();
this.hide();
} else {
this.render();
}
}
},
addObservers: function(element) {
Event.observe(element, "mouseover", this.onHover.bindAsEventListener(this));
Event.observe(element, "click", this.onClick.bindAsEventListener(this));
},
onObserverEvent: function() {
this.changed = false;
this.tokenBounds = null;
if(this.getToken().length>=this.options.minChars) {
this.getUpdatedChoices();
} else {
this.active = false;
this.hide();
}
this.oldElementValue = this.element.value;
},
getToken: function() {
var bounds = this.getTokenBounds();
return this.element.value.substring(bounds[0], bounds[1]).strip();
},
getTokenBounds: function() {
if (null != this.tokenBounds) return this.tokenBounds;
var value = this.element.value;
if (value.strip().empty()) return [-1, 0];
var diff = arguments.callee.getFirstDifferencePos(value, this.oldElementValue);
var offset = (diff == this.oldElementValue.length ? 1 : 0);
var prevTokenPos = -1, nextTokenPos = value.length;
var tp;
for (var index = 0, l = this.options.tokens.length; index < l; ++index) {
tp = value.lastIndexOf(this.options.tokens[index], diff + offset - 1);
if (tp > prevTokenPos) prevTokenPos = tp;
tp = value.indexOf(this.options.tokens[index], diff + offset);
if (-1 != tp && tp < nextTokenPos) nextTokenPos = tp;
}
return (this.tokenBounds = [prevTokenPos + 1, nextTokenPos]);
}
});
Autocompleter.Base.prototype.getTokenBounds.getFirstDifferencePos = function(newS, oldS) {
var boundary = Math.min(newS.length, oldS.length);
for (var index = 0; index < boundary; ++index)
if (newS[index] != oldS[index])
return index;
return boundary;
};
Ajax.Autocompleter = Class.create(Autocompleter.Base, {
initialize: function(element, update, url, options) {
this.baseInitialize(element, update, options);
this.options.asynchronous = true;
this.options.onComplete = this.onComplete.bind(this);
this.options.defaultParams = this.options.parameters || null;
this.url = url;
},
getUpdatedChoices: function() {
this.startIndicator();
var entry = encodeURIComponent(this.options.paramName) + '=' +
encodeURIComponent(this.getToken());
this.options.parameters = this.options.callback ?
this.options.callback(this.element, entry) : entry;
if(this.options.defaultParams)
this.options.parameters += '&' + this.options.defaultParams;
new Ajax.Request(this.url, this.options);
},
onComplete: function(request) {
this.updateChoices(request.responseText);
}
});
// The local array autocompleter. Used when you'd prefer to
// inject an array of autocompletion options into the page, rather
// than sending out Ajax queries, which can be quite slow sometimes.
//
// The constructor takes four parameters. The first two are, as usual,
// the id of the monitored textbox, and id of the autocompletion menu.
// The third is the array you want to autocomplete from, and the fourth
// is the options block.
//
// Extra local autocompletion options:
// - choices - How many autocompletion choices to offer
//
// - partialSearch - If false, the autocompleter will match entered
// text only at the beginning of strings in the
// autocomplete array. Defaults to true, which will
// match text at the beginning of any *word* in the
// strings in the autocomplete array. If you want to
// search anywhere in the string, additionally set
// the option fullSearch to true (default: off).
//
// - fullSsearch - Search anywhere in autocomplete array strings.
//
// - partialChars - How many characters to enter before triggering
// a partial match (unlike minChars, which defines
// how many characters are required to do any match
// at all). Defaults to 2.
//
// - ignoreCase - Whether to ignore case when autocompleting.
// Defaults to true.
//
// It's possible to pass in a custom function as the 'selector'
// option, if you prefer to write your own autocompletion logic.
// In that case, the other options above will not apply unless
// you support them.
Autocompleter.Local = Class.create(Autocompleter.Base, {
initialize: function(element, update, array, options) {
this.baseInitialize(element, update, options);
this.options.array = array;
},
getUpdatedChoices: function() {
this.updateChoices(this.options.selector(this));
},
setOptions: function(options) {
this.options = Object.extend({
choices: 10,
partialSearch: true,
partialChars: 2,
ignoreCase: true,
fullSearch: false,
selector: function(instance) {
var ret = []; // Beginning matches
var partial = []; // Inside matches
var entry = instance.getToken();
var count = 0;
for (var i = 0; i < instance.options.array.length &&
ret.length < instance.options.choices ; i++) {
var elem = instance.options.array[i];
var foundPos = instance.options.ignoreCase ?
elem.toLowerCase().indexOf(entry.toLowerCase()) :
elem.indexOf(entry);
while (foundPos != -1) {
if (foundPos == 0 && elem.length != entry.length) {
ret.push("<li><strong>" + elem.substr(0, entry.length) + "</strong>" +
elem.substr(entry.length) + "</li>");
break;
} else if (entry.length >= instance.options.partialChars &&
instance.options.partialSearch && foundPos != -1) {
if (instance.options.fullSearch || /\s/.test(elem.substr(foundPos-1,1))) {
partial.push("<li>" + elem.substr(0, foundPos) + "<strong>" +
elem.substr(foundPos, entry.length) + "</strong>" + elem.substr(
foundPos + entry.length) + "</li>");
break;
}
}
foundPos = instance.options.ignoreCase ?
elem.toLowerCase().indexOf(entry.toLowerCase(), foundPos + 1) :
elem.indexOf(entry, foundPos + 1);
}
}
if (partial.length)
ret = ret.concat(partial.slice(0, instance.options.choices - ret.length));
return "<ul>" + ret.join('') + "</ul>";
}
}, options || { });
}
});
// AJAX in-place editor and collection editor
// Full rewrite by Christophe Porteneuve <tdd@tddsworld.com> (April 2007).
// Use this if you notice weird scrolling problems on some browsers,
// the DOM might be a bit confused when this gets called so do this
// waits 1 ms (with setTimeout) until it does the activation
Field.scrollFreeActivate = function(field) {
setTimeout(function() {
Field.activate(field);
}, 1);
};
Ajax.InPlaceEditor = Class.create({
initialize: function(element, url, options) {
this.url = url;
this.element = element = $(element);
this.prepareOptions();
this._controls = { };
arguments.callee.dealWithDeprecatedOptions(options); // DEPRECATION LAYER!!!
Object.extend(this.options, options || { });
if (!this.options.formId && this.element.id) {
this.options.formId = this.element.id + '-inplaceeditor';
if ($(this.options.formId))
this.options.formId = '';
}
if (this.options.externalControl)
this.options.externalControl = $(this.options.externalControl);
if (!this.options.externalControl)
this.options.externalControlOnly = false;
this._originalBackground = this.element.getStyle('background-color') || 'transparent';
this.element.title = this.options.clickToEditText;
this._boundCancelHandler = this.handleFormCancellation.bind(this);
this._boundComplete = (this.options.onComplete || Prototype.emptyFunction).bind(this);
this._boundFailureHandler = this.handleAJAXFailure.bind(this);
this._boundSubmitHandler = this.handleFormSubmission.bind(this);
this._boundWrapperHandler = this.wrapUp.bind(this);
this.registerListeners();
},
checkForEscapeOrReturn: function(e) {
if (!this._editing || e.ctrlKey || e.altKey || e.shiftKey) return;
if (Event.KEY_ESC == e.keyCode)
this.handleFormCancellation(e);
else if (Event.KEY_RETURN == e.keyCode)
this.handleFormSubmission(e);
},
createControl: function(mode, handler, extraClasses) {
var control = this.options[mode + 'Control'];
var text = this.options[mode + 'Text'];
if ('button' == control) {
var btn = document.createElement('input');
btn.type = 'submit';
btn.value = text;
btn.className = 'editor_' + mode + '_button';
if ('cancel' == mode)
btn.onclick = this._boundCancelHandler;
this._form.appendChild(btn);
this._controls[mode] = btn;
} else if ('link' == control) {
var link = document.createElement('a');
link.href = '#';
link.appendChild(document.createTextNode(text));
link.onclick = 'cancel' == mode ? this._boundCancelHandler : this._boundSubmitHandler;
link.className = 'editor_' + mode + '_link';
if (extraClasses)
link.className += ' ' + extraClasses;
this._form.appendChild(link);
this._controls[mode] = link;
}
},
createEditField: function() {
var text = (this.options.loadTextURL ? this.options.loadingText : this.getText());
var fld;
if (1 >= this.options.rows && !/\r|\n/.test(this.getText())) {
fld = document.createElement('input');
fld.type = 'text';
var size = this.options.size || this.options.cols || 0;
if (0 < size) fld.size = size;
} else {
fld = document.createElement('textarea');
fld.rows = (1 >= this.options.rows ? this.options.autoRows : this.options.rows);
fld.cols = this.options.cols || 40;
}
fld.name = this.options.paramName;
fld.value = text; // No HTML breaks conversion anymore
fld.className = 'editor_field';
if (this.options.submitOnBlur)
fld.onblur = this._boundSubmitHandler;
this._controls.editor = fld;
if (this.options.loadTextURL)
this.loadExternalText();
this._form.appendChild(this._controls.editor);
},
createForm: function() {
var ipe = this;
function addText(mode, condition) {
var text = ipe.options['text' + mode + 'Controls'];
if (!text || condition === false) return;
ipe._form.appendChild(document.createTextNode(text));
};
this._form = $(document.createElement('form'));
this._form.id = this.options.formId;
this._form.addClassName(this.options.formClassName);
this._form.onsubmit = this._boundSubmitHandler;
this.createEditField();
if ('textarea' == this._controls.editor.tagName.toLowerCase())
this._form.appendChild(document.createElement('br'));
if (this.options.onFormCustomization)
this.options.onFormCustomization(this, this._form);
addText('Before', this.options.okControl || this.options.cancelControl);
this.createControl('ok', this._boundSubmitHandler);
addText('Between', this.options.okControl && this.options.cancelControl);
this.createControl('cancel', this._boundCancelHandler, 'editor_cancel');
addText('After', this.options.okControl || this.options.cancelControl);
},
destroy: function() {
if (this._oldInnerHTML)
this.element.innerHTML = this._oldInnerHTML;
this.leaveEditMode();
this.unregisterListeners();
},
enterEditMode: function(e) {
if (this._saving || this._editing) return;
this._editing = true;
this.triggerCallback('onEnterEditMode');
if (this.options.externalControl)
this.options.externalControl.hide();
this.element.hide();
this.createForm();
this.element.parentNode.insertBefore(this._form, this.element);
if (!this.options.loadTextURL)
this.postProcessEditField();
if (e) Event.stop(e);
},
enterHover: function(e) {
if (this.options.hoverClassName)
this.element.addClassName(this.options.hoverClassName);
if (this._saving) return;
this.triggerCallback('onEnterHover');
},
getText: function() {
return this.element.innerHTML.unescapeHTML();
},
handleAJAXFailure: function(transport) {
this.triggerCallback('onFailure', transport);
if (this._oldInnerHTML) {
this.element.innerHTML = this._oldInnerHTML;
this._oldInnerHTML = null;
}
},
handleFormCancellation: function(e) {
this.wrapUp();
if (e) Event.stop(e);
},
handleFormSubmission: function(e) {
var form = this._form;
var value = $F(this._controls.editor);
this.prepareSubmission();
var params = this.options.callback(form, value) || '';
if (Object.isString(params))
params = params.toQueryParams();
params.editorId = this.element.id;
if (this.options.htmlResponse) {
var options = Object.extend({ evalScripts: true }, this.options.ajaxOptions);
Object.extend(options, {
parameters: params,
onComplete: this._boundWrapperHandler,
onFailure: this._boundFailureHandler
});
new Ajax.Updater({ success: this.element }, this.url, options);
} else {
var options = Object.extend({ method: 'get' }, this.options.ajaxOptions);
Object.extend(options, {
parameters: params,
onComplete: this._boundWrapperHandler,
onFailure: this._boundFailureHandler
});
new Ajax.Request(this.url, options);
}
if (e) Event.stop(e);
},
leaveEditMode: function() {
this.element.removeClassName(this.options.savingClassName);
this.removeForm();
this.leaveHover();
this.element.style.backgroundColor = this._originalBackground;
this.element.show();
if (this.options.externalControl)
this.options.externalControl.show();
this._saving = false;
this._editing = false;
this._oldInnerHTML = null;
this.triggerCallback('onLeaveEditMode');
},
leaveHover: function(e) {
if (this.options.hoverClassName)
this.element.removeClassName(this.options.hoverClassName);
if (this._saving) return;
this.triggerCallback('onLeaveHover');
},
loadExternalText: function() {
this._form.addClassName(this.options.loadingClassName);
this._controls.editor.disabled = true;
var options = Object.extend({ method: 'get' }, this.options.ajaxOptions);
Object.extend(options, {
parameters: 'editorId=' + encodeURIComponent(this.element.id),
onComplete: Prototype.emptyFunction,
onSuccess: function(transport) {
this._form.removeClassName(this.options.loadingClassName);
var text = transport.responseText;
if (this.options.stripLoadedTextTags)
text = text.stripTags();
this._controls.editor.value = text;
this._controls.editor.disabled = false;
this.postProcessEditField();
}.bind(this),
onFailure: this._boundFailureHandler
});
new Ajax.Request(this.options.loadTextURL, options);
},
postProcessEditField: function() {
var fpc = this.options.fieldPostCreation;
if (fpc)
$(this._controls.editor)['focus' == fpc ? 'focus' : 'activate']();
},
prepareOptions: function() {
this.options = Object.clone(Ajax.InPlaceEditor.DefaultOptions);
Object.extend(this.options, Ajax.InPlaceEditor.DefaultCallbacks);
[this._extraDefaultOptions].flatten().compact().each(function(defs) {
Object.extend(this.options, defs);
}.bind(this));
},
prepareSubmission: function() {
this._saving = true;
this.removeForm();
this.leaveHover();
this.showSaving();
},
registerListeners: function() {
this._listeners = { };
var listener;
$H(Ajax.InPlaceEditor.Listeners).each(function(pair) {
listener = this[pair.value].bind(this);
this._listeners[pair.key] = listener;
if (!this.options.externalControlOnly)
this.element.observe(pair.key, listener);
if (this.options.externalControl)
this.options.externalControl.observe(pair.key, listener);
}.bind(this));
},
removeForm: function() {
if (!this._form) return;
this._form.remove();
this._form = null;
this._controls = { };
},
showSaving: function() {
this._oldInnerHTML = this.element.innerHTML;
this.element.innerHTML = this.options.savingText;
this.element.addClassName(this.options.savingClassName);
this.element.style.backgroundColor = this._originalBackground;
this.element.show();
},
triggerCallback: function(cbName, arg) {
if ('function' == typeof this.options[cbName]) {
this.options[cbName](this, arg);
}
},
unregisterListeners: function() {
$H(this._listeners).each(function(pair) {
if (!this.options.externalControlOnly)
this.element.stopObserving(pair.key, pair.value);
if (this.options.externalControl)
this.options.externalControl.stopObserving(pair.key, pair.value);
}.bind(this));
},
wrapUp: function(transport) {
this.leaveEditMode();
// Can't use triggerCallback due to backward compatibility: requires
// binding + direct element
this._boundComplete(transport, this.element);
}
});
Object.extend(Ajax.InPlaceEditor.prototype, {
dispose: Ajax.InPlaceEditor.prototype.destroy
});
Ajax.InPlaceCollectionEditor = Class.create(Ajax.InPlaceEditor, {
initialize: function($super, element, url, options) {
this._extraDefaultOptions = Ajax.InPlaceCollectionEditor.DefaultOptions;
$super(element, url, options);
},
createEditField: function() {
var list = document.createElement('select');
list.name = this.options.paramName;
list.size = 1;
this._controls.editor = list;
this._collection = this.options.collection || [];
if (this.options.loadCollectionURL)
this.loadCollection();
else
this.checkForExternalText();
this._form.appendChild(this._controls.editor);
},
loadCollection: function() {
this._form.addClassName(this.options.loadingClassName);
this.showLoadingText(this.options.loadingCollectionText);
var options = Object.extend({ method: 'get' }, this.options.ajaxOptions);
Object.extend(options, {
parameters: 'editorId=' + encodeURIComponent(this.element.id),
onComplete: Prototype.emptyFunction,
onSuccess: function(transport) {
var js = transport.responseText.strip();
if (!/^\[.*\]$/.test(js)) // TODO: improve sanity check
throw('Server returned an invalid collection representation.');
this._collection = eval(js);
this.checkForExternalText();
}.bind(this),
onFailure: this.onFailure
});
new Ajax.Request(this.options.loadCollectionURL, options);
},
showLoadingText: function(text) {
this._controls.editor.disabled = true;
var tempOption = this._controls.editor.firstChild;
if (!tempOption) {
tempOption = document.createElement('option');
tempOption.value = '';
this._controls.editor.appendChild(tempOption);
tempOption.selected = true;
}
tempOption.update((text || '').stripScripts().stripTags());
},
checkForExternalText: function() {
this._text = this.getText();
if (this.options.loadTextURL)
this.loadExternalText();
else
this.buildOptionList();
},
loadExternalText: function() {
this.showLoadingText(this.options.loadingText);
var options = Object.extend({ method: 'get' }, this.options.ajaxOptions);
Object.extend(options, {
parameters: 'editorId=' + encodeURIComponent(this.element.id),
onComplete: Prototype.emptyFunction,
onSuccess: function(transport) {
this._text = transport.responseText.strip();
this.buildOptionList();
}.bind(this),
onFailure: this.onFailure
});
new Ajax.Request(this.options.loadTextURL, options);
},
buildOptionList: function() {
this._form.removeClassName(this.options.loadingClassName);
this._collection = this._collection.map(function(entry) {
return 2 === entry.length ? entry : [entry, entry].flatten();
});
var marker = ('value' in this.options) ? this.options.value : this._text;
var textFound = this._collection.any(function(entry) {
return entry[0] == marker;
}.bind(this));
this._controls.editor.update('');
var option;
this._collection.each(function(entry, index) {
option = document.createElement('option');
option.value = entry[0];
option.selected = textFound ? entry[0] == marker : 0 == index;
option.appendChild(document.createTextNode(entry[1]));
this._controls.editor.appendChild(option);
}.bind(this));
this._controls.editor.disabled = false;
Field.scrollFreeActivate(this._controls.editor);
}
});
//**** DEPRECATION LAYER FOR InPlace[Collection]Editor! ****
//**** This only exists for a while, in order to let ****
//**** users adapt to the new API. Read up on the new ****
//**** API and convert your code to it ASAP! ****
Ajax.InPlaceEditor.prototype.initialize.dealWithDeprecatedOptions = function(options) {
if (!options) return;
function fallback(name, expr) {
if (name in options || expr === undefined) return;
options[name] = expr;
};
fallback('cancelControl', (options.cancelLink ? 'link' : (options.cancelButton ? 'button' :
options.cancelLink == options.cancelButton == false ? false : undefined)));
fallback('okControl', (options.okLink ? 'link' : (options.okButton ? 'button' :
options.okLink == options.okButton == false ? false : undefined)));
fallback('highlightColor', options.highlightcolor);
fallback('highlightEndColor', options.highlightendcolor);
};
Object.extend(Ajax.InPlaceEditor, {
DefaultOptions: {
ajaxOptions: { },
autoRows: 3, // Use when multi-line w/ rows == 1
cancelControl: 'link', // 'link'|'button'|false
cancelText: 'cancel',
clickToEditText: 'Click to edit',
externalControl: null, // id|elt
externalControlOnly: false,
fieldPostCreation: 'activate', // 'activate'|'focus'|false
formClassName: 'inplaceeditor-form',
formId: null, // id|elt
highlightColor: '#ffff99',
highlightEndColor: '#ffffff',
hoverClassName: '',
htmlResponse: true,
loadingClassName: 'inplaceeditor-loading',
loadingText: 'Loading...',
okControl: 'button', // 'link'|'button'|false
okText: 'ok',
paramName: 'value',
rows: 1, // If 1 and multi-line, uses autoRows
savingClassName: 'inplaceeditor-saving',
savingText: 'Saving...',
size: 0,
stripLoadedTextTags: false,
submitOnBlur: false,
textAfterControls: '',
textBeforeControls: '',
textBetweenControls: ''
},
DefaultCallbacks: {
callback: function(form) {
return Form.serialize(form);
},
onComplete: function(transport, element) {
// For backward compatibility, this one is bound to the IPE, and passes
// the element directly. It was too often customized, so we don't break it.
new Effect.Highlight(element, {
startcolor: this.options.highlightColor, keepBackgroundImage: true });
},
onEnterEditMode: null,
onEnterHover: function(ipe) {
ipe.element.style.backgroundColor = ipe.options.highlightColor;
if (ipe._effect)
ipe._effect.cancel();
},
onFailure: function(transport, ipe) {
alert('Error communication with the server: ' + transport.responseText.stripTags());
},
onFormCustomization: null, // Takes the IPE and its generated form, after editor, before controls.
onLeaveEditMode: null,
onLeaveHover: function(ipe) {
ipe._effect = new Effect.Highlight(ipe.element, {
startcolor: ipe.options.highlightColor, endcolor: ipe.options.highlightEndColor,
restorecolor: ipe._originalBackground, keepBackgroundImage: true
});
}
},
Listeners: {
click: 'enterEditMode',
keydown: 'checkForEscapeOrReturn',
mouseover: 'enterHover',
mouseout: 'leaveHover'
}
});
Ajax.InPlaceCollectionEditor.DefaultOptions = {
loadingCollectionText: 'Loading options...'
};
// Delayed observer, like Form.Element.Observer,
// but waits for delay after last key input
// Ideal for live-search fields
Form.Element.DelayedObserver = Class.create({
initialize: function(element, delay, callback) {
this.delay = delay || 0.5;
this.element = $(element);
this.callback = callback;
this.timer = null;
this.lastValue = $F(this.element);
Event.observe(this.element,'keyup',this.delayedListener.bindAsEventListener(this));
},
delayedListener: function(event) {
if(this.lastValue == $F(this.element)) return;
if(this.timer) clearTimeout(this.timer);
this.timer = setTimeout(this.onTimerEvent.bind(this), this.delay * 1000);
this.lastValue = $F(this.element);
},
onTimerEvent: function() {
this.timer = null;
this.callback(this.element, $F(this.element));
}
});

File diff suppressed because it is too large Load Diff

@ -1,68 +0,0 @@
// script.aculo.us scriptaculous.js v1.9.0, Thu Dec 23 16:54:48 -0500 2010
// Copyright (c) 2005-2010 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us)
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
// For details, see the script.aculo.us web site: http://script.aculo.us/
var Scriptaculous = {
Version: '1.9.0',
require: function(libraryName) {
try{
// inserting via DOM fails in Safari 2.0, so brute force approach
document.write('<script type="text/javascript" src="'+libraryName+'"><\/script>');
} catch(e) {
// for xhtml+xml served content, fall back to DOM methods
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = libraryName;
document.getElementsByTagName('head')[0].appendChild(script);
}
},
REQUIRED_PROTOTYPE: '1.6.0.3',
load: function() {
function convertVersionString(versionString) {
var v = versionString.replace(/_.*|\./g, '');
v = parseInt(v + '0'.times(4-v.length));
return versionString.indexOf('_') > -1 ? v-1 : v;
}
if((typeof Prototype=='undefined') ||
(typeof Element == 'undefined') ||
(typeof Element.Methods=='undefined') ||
(convertVersionString(Prototype.Version) <
convertVersionString(Scriptaculous.REQUIRED_PROTOTYPE)))
throw("script.aculo.us requires the Prototype JavaScript framework >= " +
Scriptaculous.REQUIRED_PROTOTYPE);
var js = /scriptaculous\.js(\?.*)?$/;
$$('script[src]').findAll(function(s) {
return s.src.match(js);
}).each(function(s) {
var path = s.src.replace(js, ''),
includes = s.src.match(/\?.*load=([a-z,]*)/);
(includes ? includes[1] : 'builder,effects,dragdrop,controls,slider,sound').split(',').each(
function(include) { Scriptaculous.require(path+include+'.js') });
});
}
};
Scriptaculous.load();

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

Binary file not shown.

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

Loading…
Cancel
Save