Dynamic OPML (#4407)

* Dynamic OPML draft
#fix https://github.com/FreshRSS/FreshRSS/issues/4191

* Export dynamic OPML
http://opml.org/spec2.opml#1629043127000

* Restart with simpler approach

* Minor revert

* Export dynamic OPML also for single feeds

* Special category type for importing dynamic OPML

* Parameter for excludeMutedFeeds

* Details

* More draft

* i18n

* Fix update

* Draft manual import working

* Working manual refresh

* Draft automatic update

* Working Web refresh + fixes

* Import/export dynamic OPML settings

* Annoying numerous lines in SQL logs

* Fix minor JavaScript error

* Fix auto adding new columns

* Add require

* Add missing 🗲

* Missing space

* Disable adding new feeds to dynamic categories

* Link from import

* i18n typo

* Improve theme icon function

* Fix pink-dark
This commit is contained in:
Alexandre Alapetite 2022-07-04 09:53:26 +02:00 committed by GitHub
parent 57d571230e
commit 509c8cae63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 1138 additions and 271 deletions

View File

@ -19,6 +19,9 @@ indent_style = tab
indent_size = 4
indent_style = tab
[*.svg]
indent_style = tab
[*.xml]
indent_style = tab

View File

@ -2,6 +2,9 @@
ot = "ot"
Ths2 = "Ths2"
[default.extend-words]
ba = "ba"
[files]
extend-exclude = [
"*.fr.md",

View File

@ -40,8 +40,8 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
if (Minz_Request::isPost()) {
invalidateHttpCache();
$cat_name = Minz_Request::param('new-category');
if (!$cat_name) {
$cat_name = trim(Minz_Request::param('new-category', ''));
if ($cat_name == '') {
Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect);
}
@ -51,12 +51,16 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
Minz_Request::bad(_t('feedback.sub.category.name_exists'), $url_redirect);
}
$values = array(
'id' => $cat->id(),
'name' => $cat->name(),
);
$opml_url = checkUrl(Minz_Request::param('opml_url', ''));
if ($opml_url != '') {
$cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
$cat->_attributes('opml_url', $opml_url);
} else {
$cat->_kind(FreshRSS_Category::KIND_NORMAL);
$cat->_attributes('opml_url', null);
}
if ($catDAO->addCategory($values)) {
if ($catDAO->addCategoryObject($cat)) {
$url_redirect['a'] = 'index';
Minz_Request::good(_t('feedback.sub.category.created', $cat->name()), $url_redirect);
} else {
@ -156,6 +160,7 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
*
* Request parameter is:
* - id (of a category)
* - muted (truthy to remove only muted feeds, or falsy otherwise)
*/
public function emptyAction() {
$feedDAO = FreshRSS_Factory::createFeedDao();
@ -169,10 +174,15 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
}
// List feeds to remove then related user queries.
$feeds = $feedDAO->listByCategory($id);
$muted = Minz_Request::param('muted', null);
if ($muted !== null) {
$muted = boolval($muted);
}
if ($feedDAO->deleteFeedByCategory($id)) {
// List feeds to remove then related user queries.
$feeds = $feedDAO->listByCategory($id, $muted);
if ($feedDAO->deleteFeedByCategory($id, $muted)) {
// TODO: Delete old favicons
// Remove related queries
@ -190,4 +200,62 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
Minz_Request::forward($url_redirect, true);
}
/**
* Request parameter is:
* - id (of a category)
*/
public function refreshOpmlAction() {
$catDAO = FreshRSS_Factory::createCategoryDao();
$url_redirect = array('c' => 'subscription', 'a' => 'index');
if (Minz_Request::isPost()) {
invalidateHttpCache();
$id = Minz_Request::param('id');
if (!$id) {
Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
}
$category = $catDAO->searchById($id);
if ($category == null) {
Minz_Request::bad(_t('feedback.sub.category.not_exist'), $url_redirect);
}
invalidateHttpCache();
$ok = $category->refreshDynamicOpml();
if (Minz_Request::param('ajax')) {
Minz_Request::setGoodNotification(_t('feedback.sub.category.updated'));
$this->view->_layout(false);
} else {
if ($ok) {
Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);
} else {
Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
}
Minz_Request::forward($url_redirect, true);
}
}
}
/** @return array<string,int> */
public static function refreshDynamicOpmls() {
$successes = 0;
$errors = 0;
$catDAO = FreshRSS_Factory::createCategoryDao();
$categories = $catDAO->listCategoriesOrderUpdate(FreshRSS_Context::$user_conf->dynamic_opml_ttl_default ?? 86400);
foreach ($categories as $category) {
if ($category->refreshDynamicOpml()) {
$successes++;
} else {
$errors++;
}
}
return [
'successes' => $successes,
'errors' => $errors,
];
}
}

View File

@ -67,6 +67,10 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
$cat_id = $cat == null ? FreshRSS_CategoryDAO::DEFAULTCATEGORYID : $cat->id();
$feed = new FreshRSS_Feed($url); //Throws FreshRSS_BadUrl_Exception
$title = trim($title);
if ($title != '') {
$feed->_name($title);
}
$feed->_kind($kind);
$feed->_attributes('', $attributes);
$feed->_httpAuth($http_auth);
@ -92,19 +96,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
throw new FreshRSS_FeedNotAdded_Exception($url);
}
$values = array(
'url' => $feed->url(),
'kind' => $feed->kind(),
'category' => $feed->category(),
'name' => $title != '' ? $title : $feed->name(true),
'website' => $feed->website(),
'description' => $feed->description(),
'lastUpdate' => 0,
'httpAuth' => $feed->httpAuth(),
'attributes' => $feed->attributes(),
);
$id = $feedDAO->addFeed($values);
$id = $feedDAO->addFeedObject($feed);
if (!$id) {
// There was an error in database… we cannot say what here.
throw new FreshRSS_FeedNotAdded_Exception($url);
@ -469,7 +461,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
}
if ($pubSubHubbubEnabled && !$simplePiePush) { //We use push, but have discovered an article by pull!
$text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' . $url .
$text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' .
SimplePie_Misc::url_remove_credentials($url) .
' GUID ' . $entry->guid();
Minz_Log::warning($text, PSHB_LOG);
Minz_Log::warning($text);
@ -528,7 +521,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
}
}
} elseif ($feed->url() !== $url) { // HTTP 301 Moved Permanently
Minz_Log::notice('Feed ' . $url . ' moved permanently to ' . $feed->url(false));
Minz_Log::notice('Feed ' . SimplePie_Misc::url_remove_credentials($url) .
' moved permanently to ' . SimplePie_Misc::url_remove_credentials($feed->url(false)));
$feedProperties['url'] = $feed->url();
}
@ -629,6 +623,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
$databaseDAO->minorDbMaintenance();
} else {
FreshRSS_category_Controller::refreshDynamicOpmls();
list($updated_feeds, $feed, $nb_new_articles) = self::actualizeFeed($id, $url, $force, null, $noCommit, $maxFeeds);
}

View File

@ -5,7 +5,10 @@
*/
class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
/** @var FreshRSS_EntryDAO */
private $entryDAO;
/** @var FreshRSS_FeedDAO */
private $feedDAO;
/**
@ -96,7 +99,8 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
$importService = new FreshRSS_Import_Service($username);
foreach ($list_files['opml'] as $opml_file) {
if (!$importService->importOpml($opml_file)) {
$importService->importOpml($opml_file);
if (!$importService->lastStatus()) {
$ok = false;
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, 'FreshRSS error during OPML import' . "\n");
@ -520,7 +524,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
$feed->_name($name);
$feed->_website($website);
if (!empty($origin['disable'])) {
$feed->_ttl(-1 * FreshRSS_Context::$user_conf->ttl_default);
$feed->_mute(true);
}
// Call the extension hook

View File

@ -174,6 +174,76 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
header('Content-Type: application/rss+xml; charset=utf-8');
}
public function opmlAction() {
$allow_anonymous = FreshRSS_Context::$system_conf->allow_anonymous;
$token = FreshRSS_Context::$user_conf->token;
$token_param = Minz_Request::param('token', '');
$token_is_ok = ($token != '' && $token === $token_param);
// Check if user has access.
if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous && !$token_is_ok) {
Minz_Error::error(403);
}
try {
$this->updateContext();
} catch (FreshRSS_Context_Exception $e) {
Minz_Error::error(404);
}
$get = FreshRSS_Context::currentGet(true);
if (is_array($get)) {
$type = $get[0];
$id = $get[1];
} else {
$type = $get;
$id = '';
}
$catDAO = FreshRSS_Factory::createCategoryDao();
$categories = $catDAO->listCategories(true, true);
$this->view->excludeMutedFeeds = true;
switch ($type) {
case 'a':
$this->view->categories = $categories;
break;
case 'c':
$cat = $categories[$id] ?? null;
if ($cat == null) {
Minz_Error::error(404);
return;
}
$this->view->categories = [ $cat ];
break;
case 'f':
// We most likely already have the feed object in cache
$feed = FreshRSS_CategoryDAO::findFeed($categories, $id);
if ($feed == null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$feed = $feedDAO->searchById($id);
if ($feed == null) {
Minz_Error::error(404);
return;
}
}
$this->view->feeds = [ $feed ];
break;
case 's':
case 't':
case 'T':
default:
Minz_Error::error(404);
return;
}
require_once(LIB_PATH . '/lib_opml.php');
// No layout for OPML output.
$this->view->_layout(false);
header('Content-Type: application/xml; charset=utf-8');
}
/**
* This action updates the Context object by using request parameters.
*

View File

@ -8,6 +8,10 @@ class FreshRSS_javascript_Controller extends FreshRSS_ActionController {
public function actualizeAction() {
header('Content-Type: application/json; charset=UTF-8');
Minz_Session::_param('actualize_feeds', false);
$catDAO = FreshRSS_Factory::createCategoryDao();
$this->view->categories = $catDAO->listCategoriesOrderUpdate(FreshRSS_Context::$user_conf->dynamic_opml_ttl_default);
$feedDAO = FreshRSS_Factory::createFeedDao();
$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
}

View File

@ -19,7 +19,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
$catDAO->checkDefault();
$feedDAO->updateTTL();
$this->view->categories = $catDAO->listSortedCategories(false);
$this->view->categories = $catDAO->listSortedCategories(false, true, true);
$this->view->default_category = $catDAO->getDefault();
$signalError = false;
@ -120,11 +120,8 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
$cat = intval(Minz_Request::param('category', 0));
$mute = Minz_Request::param('mute', false);
$ttl = intval(Minz_Request::param('ttl', FreshRSS_Feed::TTL_DEFAULT));
if ($mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) {
$ttl = FreshRSS_Context::$user_conf->ttl_default;
}
$feed->_ttl(intval(Minz_Request::param('ttl', FreshRSS_Feed::TTL_DEFAULT)));
$feed->_mute(boolval(Minz_Request::param('mute', false)));
$feed->_attributes('read_upon_gone', Minz_Request::paramTernary('read_upon_gone'));
$feed->_attributes('mark_updated_article_unread', Minz_Request::paramTernary('mark_updated_article_unread'));
@ -196,8 +193,8 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
$feed->_filtersAction('read', preg_split('/[\n\r]+/', Minz_Request::param('filteractions_read', '')));
$feed_kind = Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS);
if ($feed_kind == FreshRSS_Feed::KIND_HTML_XPATH) {
$feed->_kind(intval(Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS)));
if ($feed->kind() == FreshRSS_Feed::KIND_HTML_XPATH) {
$xPathSettings = [];
if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true);
if (Minz_Request::param('xPathItemTitle', '') != '') $xPathSettings['itemTitle'] = Minz_Request::param('xPathItemTitle', '', true);
@ -214,7 +211,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
$values = array(
'name' => Minz_Request::param('name', ''),
'kind' => $feed_kind,
'kind' => $feed->kind(),
'description' => sanitizeHTML(Minz_Request::param('description', '', true)),
'website' => checkUrl(Minz_Request::param('website', '')),
'url' => checkUrl(Minz_Request::param('url', '')),
@ -222,7 +219,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
'pathEntries' => Minz_Request::param('path_entries', ''),
'priority' => intval(Minz_Request::param('priority', FreshRSS_Feed::PRIORITY_MAIN_STREAM)),
'httpAuth' => $httpAuth,
'ttl' => $ttl * ($mute ? -1 : 1),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
);
@ -300,7 +297,17 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
$position = Minz_Request::param('position');
$category->_attributes('position', '' === $position ? null : (int) $position);
$opml_url = checkUrl(Minz_Request::param('opml_url', ''));
if ($opml_url != '') {
$category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
$category->_attributes('opml_url', $opml_url);
} else {
$category->_kind(FreshRSS_Category::KIND_NORMAL);
$category->_attributes('opml_url', null);
}
$values = [
'kind' => $category->kind(),
'name' => Minz_Request::param('name', ''),
'attributes' => $category->attributes(),
];

View File

@ -1,17 +1,38 @@
<?php
class FreshRSS_Category extends Minz_Model {
/**
* Normal
* @var int
*/
const KIND_NORMAL = 0;
/**
* Category tracking a third-party Dynamic OPML
* @var int
*/
const KIND_DYNAMIC_OPML = 2;
const TTL_DEFAULT = 0;
/**
* @var int
*/
private $id = 0;
/** @var int */
private $kind = 0;
private $name;
private $nbFeeds = -1;
private $nbNotRead = -1;
/** @var array<FreshRSS_Feed>|null */
private $feeds = null;
private $hasFeedsWithError = false;
private $isDefault = false;
private $attributes = [];
/** @var int */
private $lastUpdate = 0;
/** @var bool */
private $error = false;
public function __construct(string $name = '', $feeds = null) {
$this->_name($name);
@ -30,11 +51,26 @@ class FreshRSS_Category extends Minz_Model {
public function id(): int {
return $this->id;
}
public function kind(): int {
return $this->kind;
}
public function name(): string {
return $this->name;
}
public function lastUpdate(): int {
return $this->lastUpdate;
}
public function _lastUpdate(int $value) {
$this->lastUpdate = $value;
}
public function inError(): bool {
return $this->error;
}
public function _error($value) {
$this->error = (bool)$value;
}
public function isDefault(): bool {
return $this->isDefault;
return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
}
public function nbFeeds(): int {
if ($this->nbFeeds < 0) {
@ -52,6 +88,8 @@ class FreshRSS_Category extends Minz_Model {
return $this->nbNotRead;
}
/** @return array<FreshRSS_Feed> */
public function feeds(): array {
if ($this->feeds === null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
@ -90,12 +128,15 @@ class FreshRSS_Category extends Minz_Model {
$this->_name(_t('gen.short.default_category'));
}
}
public function _kind(int $kind) {
$this->kind = $kind;
}
public function _name($value) {
$this->name = mb_strcut(trim($value), 0, 255, 'UTF-8');
}
public function _isDefault($value) {
$this->isDefault = $value;
}
/** @param array<FreshRSS_Feed>|FreshRSS_Feed $values */
public function _feeds($values) {
if (!is_array($values)) {
$values = array($values);
@ -104,6 +145,17 @@ class FreshRSS_Category extends Minz_Model {
$this->feeds = $values;
}
/**
* To manually add feeds to this category (not committing to database).
* @param FreshRSS_Feed $feed
*/
public function addFeed($feed) {
if ($this->feeds === null) {
$this->feeds = [];
}
$this->feeds[] = $feed;
}
public function _attributes($key, $value) {
if ('' == $key) {
if (is_string($value)) {
@ -118,4 +170,78 @@ class FreshRSS_Category extends Minz_Model {
$this->attributes[$key] = $value;
}
}
public static function cacheFilename(string $url, array $attributes): string {
$simplePie = customSimplePie($attributes);
$filename = $simplePie->get_cache_filename($url);
return CACHE_PATH . '/' . $filename . '.opml.xml';
}
public function refreshDynamicOpml(): bool {
$url = $this->attributes('opml_url');
if ($url == '') {
return false;
}
$ok = true;
$attributes = []; //TODO
$cachePath = self::cacheFilename($url, $attributes);
$opml = httpGet($url, $cachePath, 'opml', $attributes);
if ($opml == '') {
Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' .
SimplePie_Misc::url_remove_credentials($url));
$ok = false;
} else {
$dryRunCategory = new FreshRSS_Category();
$importService = new FreshRSS_Import_Service();
$importService->importOpml($opml, $dryRunCategory, true, true);
if ($importService->lastStatus()) {
$feedDAO = FreshRSS_Factory::createFeedDao();
/** @var array<string,FreshRSS_Feed> */
$dryRunFeeds = [];
foreach ($dryRunCategory->feeds() as $dryRunFeed) {
$dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed;
}
/** @var array<string,FreshRSS_Feed> */
$existingFeeds = [];
foreach ($this->feeds() as $existingFeed) {
$existingFeeds[$existingFeed->url()] = $existingFeed;
if (empty($dryRunFeeds[$existingFeed->url()])) {
// The feed does not exist in the new dynamic OPML, so mute (disable) that feed
$existingFeed->_mute(true);
$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
'ttl' => $existingFeed->ttl(true),
]) !== false);
}
}
foreach ($dryRunCategory->feeds() as $dryRunFeed) {
if (empty($existingFeeds[$dryRunFeed->url()])) {
// The feed does not exist in the current category, so add that feed
$dryRunFeed->_category($this->id());
$ok &= ($feedDAO->addFeedObject($dryRunFeed) !== false);
} else {
$existingFeed = $existingFeeds[$dryRunFeed->url()];
if ($existingFeed->mute()) {
// The feed already exists in the current category but was muted (disabled), so unmute (enable) again
$existingFeed->_mute(false);
$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
'ttl' => $existingFeed->ttl(true),
]) !== false);
}
}
}
} else {
$ok = false;
Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' .
SimplePie_Misc::url_remove_credentials($url));
}
}
$catDAO = FreshRSS_Factory::createCategoryDao();
$catDAO->updateLastUpdate($this->id(), !$ok);
return $ok;
}
}

View File

@ -17,7 +17,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
protected function addColumn($name) {
Minz_Log::warning(__method__ . ': ' . $name);
try {
if ('attributes' === $name) { //v1.15.0
if ($name === 'kind') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN kind SMALLINT DEFAULT 0') !== false;
} elseif ($name === 'lastUpdate') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN `lastUpdate` BIGINT DEFAULT 0') !== false;
} elseif ($name === 'error') { //v1.20.0
return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN error SMALLINT DEFAULT 0') !== false;
} elseif ('attributes' === $name) { //v1.15.0
$ok = $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN attributes TEXT') !== false;
$stm = $this->pdo->query('SELECT * FROM `_feed`');
@ -69,8 +75,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
protected function autoUpdateDb(array $errorInfo) {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
foreach (['attributes'] as $column) {
if (stripos($errorInfo[2], $column) !== false) {
$errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
@ -79,12 +86,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
return false;
}
/** @return int|false */
public function addCategory($valuesTmp) {
// TRIM() to provide a type hint as text
// No tag of the same name
$sql = <<<'SQL'
INSERT INTO `_category`(name, attributes)
SELECT * FROM (SELECT TRIM(?) AS name, TRIM(?) AS attributes) c2
INSERT INTO `_category`(kind, name, attributes)
SELECT * FROM (SELECT ABS(?) AS kind, TRIM(?) AS name, TRIM(?) AS attributes) c2
WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = TRIM(?))
SQL;
$stm = $this->pdo->prepare($sql);
@ -94,6 +102,7 @@ SQL;
$valuesTmp['attributes'] = [];
}
$values = array(
$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
$valuesTmp['name'],
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES),
$valuesTmp['name'],
@ -111,13 +120,18 @@ SQL;
}
}
/**
* @param FreshRSS_Category $category
* @return int|false
*/
public function addCategoryObject($category) {
$cat = $this->searchByName($category->name());
if (!$cat) {
// Category does not exist yet in DB so we add it before continue
$values = array(
$values = [
'kind' => $category->kind(),
'name' => $category->name(),
);
'attributes' => $category->attributes(),
];
return $this->addCategory($values);
}
@ -127,7 +141,7 @@ SQL;
public function updateCategory($id, $valuesTmp) {
// No tag of the same name
$sql = <<<'SQL'
UPDATE `_category` SET name=?, attributes=? WHERE id=?
UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=?
AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = ?)
SQL;
$stm = $this->pdo->prepare($sql);
@ -138,6 +152,7 @@ SQL;
}
$values = array(
$valuesTmp['name'],
$valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL,
is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES),
$id,
$valuesTmp['name'],
@ -155,6 +170,24 @@ SQL;
}
}
public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) {
$sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?';
$values = [
$mtime <= 0 ? time() : $mtime,
$inError ? 1 : 0,
$id,
];
$stm = $this->pdo->prepare($sql);
if ($stm && $stm->execute($values)) {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return false;
}
}
public function deleteCategory($id) {
if ($id <= self::DEFAULTCATEGORYID) {
return false;
@ -172,7 +205,7 @@ SQL;
}
public function selectAll() {
$sql = 'SELECT id, name, attributes FROM `_category`';
$sql = 'SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`';
$stm = $this->pdo->query($sql);
if ($stm != false) {
while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
@ -181,15 +214,14 @@ SQL;
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
foreach ($this->selectAll() as $category) { // `yield from` requires PHP 7+
yield $category;
}
yield from $this->selectAll();
}
Minz_Log::error(__method__ . ' error: ' . json_encode($info));
yield false;
}
}
/** @return FreshRSS_Category|null */
public function searchById($id) {
$sql = 'SELECT * FROM `_category` WHERE id=:id';
$stm = $this->pdo->prepare($sql);
@ -204,7 +236,9 @@ SQL;
return null;
}
}
public function searchByName($name) {
/** @return FreshRSS_Category|null|false */
public function searchByName(string $name) {
$sql = 'SELECT * FROM `_category` WHERE name=:name';
$stm = $this->pdo->prepare($sql);
if ($stm == false) {
@ -246,7 +280,7 @@ SQL;
public function listCategories($prePopulateFeeds = true, $details = false) {
if ($prePopulateFeeds) {
$sql = 'SELECT c.id AS c_id, c.name AS c_name, c.attributes AS c_attributes, '
$sql = 'SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes, '
. ($details ? 'f.* ' : 'f.id, f.name, f.url, f.website, f.priority, f.error, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl ')
. 'FROM `_category` c '
. 'LEFT OUTER JOIN `_feed` f ON f.category=c.id '
@ -272,6 +306,27 @@ SQL;
}
}
/** @return array<FreshRSS_Category> */
public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0) {
$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
. ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
$stm = $this->pdo->prepare($sql);
if ($stm &&
$stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
$stm->bindValue(':lu', time() - $defaultCacheDuration, PDO::PARAM_INT) &&
$stm->execute()) {
return self::daoToCategory($stm->fetchAll(PDO::FETCH_ASSOC));
} else {
$info = $stm ? $stm->errorInfo() : $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listCategoriesOrderUpdate($defaultCacheDuration, $limit);
}
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return [];
}
}
/** @return FreshRSS_Category|null */
public function getDefault() {
$sql = 'SELECT * FROM `_category` WHERE id=:id';
$stm = $this->pdo->prepare($sql);
@ -290,6 +345,8 @@ SQL;
return null;
}
}
/** @return int|bool */
public function checkDefault() {
$def_cat = $this->searchById(self::DEFAULTCATEGORYID);
@ -345,6 +402,10 @@ SQL;
return $res[0]['count'];
}
/**
* @param array<FreshRSS_Category> $categories
* @param int $feed_id
*/
public static function findFeed($categories, $feed_id) {
foreach ($categories as $category) {
foreach ($category->feeds() as $feed) {
@ -356,6 +417,10 @@ SQL;
return null;
}
/**
* @param array<FreshRSS_Category> $categories
* @param int $minPriority
*/
public static function CountUnreads($categories, $minPriority = 0) {
$n = 0;
foreach ($categories as $category) {
@ -386,6 +451,7 @@ SQL;
$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
);
$cat->_id($previousLine['c_id']);
$cat->_kind($previousLine['c_kind']);
$cat->_attributes('', $previousLine['c_attributes']);
$list[$previousLine['c_id']] = $cat;
@ -403,6 +469,9 @@ SQL;
$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
);
$cat->_id($previousLine['c_id']);
$cat->_kind($previousLine['c_kind']);
$cat->_lastUpdate($previousLine['c_last_update'] ?? 0);
$cat->_error($previousLine['c_error'] ?? false);
$cat->_attributes('', $previousLine['c_attributes']);
$list[$previousLine['c_id']] = $cat;
}
@ -422,8 +491,10 @@ SQL;
$dao['name']
);
$cat->_id($dao['id']);
$cat->_kind($dao['kind']);
$cat->_lastUpdate($dao['lastUpdate'] ?? 0);
$cat->_error($dao['error'] ?? false);
$cat->_attributes('', isset($dao['attributes']) ? $dao['attributes'] : '');
$cat->_isDefault(static::DEFAULTCATEGORYID === intval($dao['id']));
$list[$key] = $cat;
}

View File

@ -5,7 +5,7 @@ class FreshRSS_CategoryDAOSQLite extends FreshRSS_CategoryDAO {
protected function autoUpdateDb(array $errorInfo) {
if ($tableInfo = $this->pdo->query("PRAGMA table_info('category')")) {
$columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1);
foreach (['attributes'] as $column) {
foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
if (!in_array($column, $columns)) {
return $this->addColumn($column);
}

View File

@ -197,6 +197,20 @@ class FreshRSS_Context {
}
}
/**
* @return bool true if the current request targets all feeds (main view), false otherwise.
*/
public static function isAll(): bool {
return self::$current_get['all'] != false;
}
/**
* @return bool true if the current request targets a category, false otherwise.
*/
public static function isCategory(): bool {
return self::$current_get['category'] != false;
}
/**
* @return bool true if the current request targets a feed (and not a category or all articles), false otherwise.
*/
@ -251,8 +265,7 @@ class FreshRSS_Context {
*/
public static function _get($get) {
$type = $get[0];
$id = substr($get, 2);
$nb_unread = 0;
$id = intval(substr($get, 2));
if (empty(self::$categories)) {
$catDAO = FreshRSS_Factory::createCategoryDao();

View File

@ -488,7 +488,8 @@ class FreshRSS_Entry extends Minz_Model {
* @param array<string,mixed> $attributes
*/
public static function getContentByParsing(string $url, string $path, array $attributes = [], int $maxRedirs = 3): string {
$html = getHtml($url, $attributes);
$cachePath = FreshRSS_Feed::cacheFilename($url, $attributes, FreshRSS_Feed::KIND_HTML_XPATH);
$html = httpGet($url, $cachePath, 'html', $attributes);
if (strlen($html) > 0) {
$doc = new DOMDocument();
$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);

View File

@ -162,9 +162,21 @@ class FreshRSS_Feed extends Minz_Model {
public function inError(): bool {
return $this->error;
}
public function ttl(): int {
/**
* @param bool $raw true for database version combined with mute information, false otherwise
*/
public function ttl(bool $raw = false): int {
if ($raw) {
$ttl = $this->ttl;
if ($this->mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) {
$ttl = FreshRSS_Context::$user_conf ? FreshRSS_Context::$user_conf->ttl_default : 3600;
}
return $ttl * ($this->mute ? -1 : 1);
}
return $this->ttl;
}
public function attributes($key = '') {
if ($key == '') {
return $this->attributes;
@ -172,19 +184,11 @@ class FreshRSS_Feed extends Minz_Model {
return isset($this->attributes[$key]) ? $this->attributes[$key] : null;
}
}
public function mute(): bool {
return $this->mute;
}
// public function ttlExpire() {
// $ttl = $this->ttl;
// if ($ttl == self::TTL_DEFAULT) { //Default
// $ttl = FreshRSS_Context::$user_conf->ttl_default;
// }
// if ($ttl == -1) { //Never
// $ttl = 64000000; //~2 years. Good enough for PubSubHubbub logic
// }
// return $this->lastUpdate + $ttl;
// }
public function nbEntries(): int {
if ($this->nbEntries < 0) {
$feedDAO = FreshRSS_Factory::createFeedDao();
@ -248,10 +252,13 @@ class FreshRSS_Feed extends Minz_Model {
public function _kind(int $value) {
$this->kind = $value;
}
/** @param int $value */
public function _category($value) {
$value = intval($value);
$this->category = $value >= 0 ? $value : 0;
}
public function _name(string $value) {
$this->name = $value == '' ? '' : trim($value);
}
@ -282,6 +289,9 @@ class FreshRSS_Feed extends Minz_Model {
public function _error($value) {
$this->error = (bool)$value;
}
public function _mute(bool $value) {
$this->mute = $value;
}
public function _ttl($value) {
$value = intval($value);
$value = min($value, 100000000);
@ -584,7 +594,8 @@ class FreshRSS_Feed extends Minz_Model {
return null;
}
$html = getHtml($feedSourceUrl, $attributes);
$cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $attributes, FreshRSS_Feed::KIND_HTML_XPATH);
$html = httpGet($feedSourceUrl, $cachePath, 'html', $attributes);
if (strlen($html) <= 0) {
return null;
}

View File

@ -19,8 +19,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
protected function autoUpdateDb(array $errorInfo) {
if (isset($errorInfo[0])) {
if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
$errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise
foreach (['attributes', 'kind'] as $column) {
if (stripos($errorInfo[2], $column) !== false) {
if (stripos($errorLines[0], $column) !== false) {
return $this->addColumn($column);
}
}
@ -29,26 +30,10 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
return false;
}
/** @return int|false */
public function addFeed(array $valuesTmp) {
$sql = '
INSERT INTO `_feed`
(
url,
kind,
category,
name,
website,
description,
`lastUpdate`,
priority,
`pathEntries`,
`httpAuth`,
error,
ttl,
attributes
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$sql = 'INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$stm = $this->pdo->prepare($sql);
$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
@ -88,10 +73,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
}
}
public function addFeedObject(FreshRSS_Feed $feed): int {
// TODO: not sure if we should write this method in DAO since DAO
// should not be aware about feed class
/** @return int|false */
public function addFeedObject(FreshRSS_Feed $feed) {
// Add feed only if we dont find it in DB
$feed_search = $this->searchByUrl($feed->url());
if (!$feed_search) {
@ -106,13 +89,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
'lastUpdate' => 0,
'pathEntries' => $feed->pathEntries(),
'httpAuth' => $feed->httpAuth(),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
);
if ($feed->mute() || (
FreshRSS_Context::$user_conf != null && //When creating a new user
$feed->ttl() != FreshRSS_Context::$user_conf->ttl_default)) {
$values['ttl'] = $feed->ttl() * ($feed->mute() ? -1 : 1);
}
$id = $this->addFeed($values);
if ($id) {
@ -121,11 +100,36 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
}
return $id;
}
} else {
// The feed already exists so make sure it is not muted
$feed->_ttl($feed_search->ttl());
$feed->_mute(false);
return $feed_search->id();
// Merge existing and import attributes
$existingAttributes = $feed_search->attributes();
$importAttributes = $feed->attributes();
$feed->_attributes('', array_merge_recursive($existingAttributes, $importAttributes));
// Update some values of the existing feed using the import
$values = [
'kind' => $feed->kind(),
'name' => $feed->name(),
'website' => $feed->website(),
'description' => $feed->description(),
'pathEntries' => $feed->pathEntries(),
'ttl' => $feed->ttl(true),
'attributes' => $feed->attributes(),
];
if (!$this->updateFeed($feed_search->id(), $values)) {
return false;
}
return $feed_search->id();
}
}
/** @return int|false */
public function updateFeed(int $id, array $valuesTmp) {
if (isset($valuesTmp['name'])) {
$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
@ -193,7 +197,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
return $stm->rowCount();
} else {
$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
Minz_Log::error('SQL error updateLastUpdate: ' . $info[2]);
Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info));
return false;
}
}
@ -227,6 +231,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
}
}
/** @return int|false */
public function deleteFeed(int $id) {
$sql = 'DELETE FROM `_feed` WHERE id=?';
$stm = $this->pdo->prepare($sql);
@ -241,8 +246,16 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
return false;
}
}
public function deleteFeedByCategory(int $id) {
/**
* @param bool|null $muted to include only muted feeds
* @return int|false
*/
public function deleteFeedByCategory(int $id, $muted = null) {
$sql = 'DELETE FROM `_feed` WHERE category=?';
if ($muted) {
$sql .= ' AND ttl < 0';
}
$stm = $this->pdo->prepare($sql);
$values = array($id);
@ -349,6 +362,7 @@ SQL;
/**
* Use $defaultCacheDuration == -1 to return all feeds, without filtering them by TTL.
* @return array<FreshRSS_Feed>
*/
public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0) {
$this->updateTTL();
@ -365,7 +379,7 @@ SQL;
} else {
$info = $this->pdo->errorInfo();
if ($this->autoUpdateDb($info)) {
return $this->listFeedsOrderUpdate($defaultCacheDuration);
return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit);
}
Minz_Log::error('SQL error listFeedsOrderUpdate: ' . $info[2]);
return array();
@ -386,10 +400,14 @@ SQL;
}
/**
* @param bool|null $muted to include only muted feeds
* @return array<FreshRSS_Feed>
*/
public function listByCategory(int $cat): array {
public function listByCategory(int $cat, $muted = null): array {
$sql = 'SELECT * FROM `_feed` WHERE category=?';
if ($muted) {
$sql .= ' AND ttl < 0';
}
$stm = $this->pdo->prepare($sql);
$stm->execute(array($cat));

View File

@ -68,6 +68,13 @@ class FreshRSS_Themes extends Minz_Model {
return $infos;
}
public static function title($name) {
static $titles = [
'opml-dyn' => 'sub.category.dynamic_opml',
];
return $titles[$name] ?? '';
}
public static function alt($name) {
static $alts = array(
'add' => '', //✚
@ -94,6 +101,7 @@ class FreshRSS_Themes extends Minz_Model {
'next' => '⏩',
'non-starred' => '☆',
'notice' => '', //ⓘ
'opml-dyn' => '🗲',
'prev' => '⏪',
'read' => '☑️', //☑
'rss' => '📣', //☄
@ -115,7 +123,13 @@ class FreshRSS_Themes extends Minz_Model {
return isset($name) ? $alts[$name] : '';
}
public static function icon($name, $urlOnly = false) {
// TODO: Change for enum in PHP 8.1+
const ICON_DEFAULT = 0;
const ICON_IMG = 1;
const ICON_URL = 2;
const ICON_EMOJI = 3;
public static function icon(string $name, int $type = self::ICON_DEFAULT): string {
$alt = self::alt($name);
if ($alt == '') {
return '';
@ -124,14 +138,29 @@ class FreshRSS_Themes extends Minz_Model {
$url = $name . '.svg';
$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);
if ($urlOnly) {
return Minz_Url::display($url);
$title = self::title($name);
if ($title != '') {
$title = ' title="' . _t($title) . '"';
}
if (FreshRSS_Context::$user_conf && FreshRSS_Context::$user_conf->icons_as_emojis) {
return '<span class="icon">' . $alt . '</span>';
if ($type == self::ICON_DEFAULT) {
if ((FreshRSS_Context::$user_conf && FreshRSS_Context::$user_conf->icons_as_emojis) ||
// default to emoji alternate for some icons
in_array($name, [ 'opml-dyn' ])) {
$type = self::ICON_EMOJI;
} else {
$type = self::ICON_IMG;
}
}
return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '" />';
switch ($type) {
case self::ICON_URL:
return Minz_Url::display($url);
case self::ICON_IMG:
return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '"' . $title . ' />';
case self::ICON_EMOJI:
default:
return '<span class="icon"' . $title . '>' . $alt . '</span>';
}
}
}

View File

@ -57,6 +57,7 @@
* @property bool $topline_summary
* @property string $topline_thumbnail
* @property int $ttl_default
* @property int $dynamic_opml_ttl_default
* @property-read bool $unsafe_autologin_enabled
* @property string $view_mode
* @property array<string,mixed> $volatile

View File

@ -25,6 +25,8 @@ class FreshRSS_View extends Minz_View {
public $tags;
/** @var array<string,string> */
public $notification;
/** @var bool */
public $excludeMutedFeeds;
// Substriptions
public $default_category;

View File

@ -7,6 +7,9 @@ $GLOBALS['SQL_CREATE_TABLES'] = <<<'SQL'
CREATE TABLE IF NOT EXISTS `_category` (
`id` INT NOT NULL AUTO_INCREMENT, -- v0.7
`name` VARCHAR(191) NOT NULL, -- Max index length for Unicode is 191 characters (767 bytes) FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE
`kind` SMALLINT DEFAULT 0, -- 1.20.0
`lastUpdate` BIGINT DEFAULT 0, -- 1.20.0
`error` SMALLINT DEFAULT 0, -- 1.20.0
`attributes` TEXT, -- v1.15.0
PRIMARY KEY (`id`),
UNIQUE KEY (`name`) -- v0.7
@ -16,7 +19,7 @@ ENGINE = INNODB;
CREATE TABLE IF NOT EXISTS `_feed` (
`id` INT NOT NULL AUTO_INCREMENT, -- v0.7
`url` VARCHAR(511) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
`kind` SMALLINT DEFAULT 0, -- 1.20.0
`kind` SMALLINT DEFAULT 0, -- 1.20.0
`category` INT DEFAULT 0, -- 1.20.0
`name` VARCHAR(191) NOT NULL,
`website` VARCHAR(255) CHARACTER SET latin1 COLLATE latin1_bin,

View File

@ -7,6 +7,9 @@ $GLOBALS['SQL_CREATE_TABLES'] = <<<'SQL'
CREATE TABLE IF NOT EXISTS `_category` (
"id" SERIAL PRIMARY KEY,
"name" VARCHAR(255) UNIQUE NOT NULL,
"kind" SMALLINT DEFAULT 0, -- 1.20.0
"lastUpdate" BIGINT DEFAULT 0, -- 1.20.0
"error" SMALLINT DEFAULT 0, -- 1.20.0
"attributes" TEXT -- v1.15.0
);

View File

@ -7,6 +7,9 @@ $GLOBALS['SQL_CREATE_TABLES'] = <<<'SQL'
CREATE TABLE IF NOT EXISTS `category` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`kind` SMALLINT DEFAULT 0, -- 1.20.0
`lastUpdate` BIGINT DEFAULT 0, -- 1.20.0
`error` SMALLINT DEFAULT 0, -- 1.20.0
`attributes` TEXT, -- v1.15.0
UNIQUE (`name`)
);
@ -14,7 +17,7 @@ CREATE TABLE IF NOT EXISTS `category` (
CREATE TABLE IF NOT EXISTS `feed` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`url` VARCHAR(511) NOT NULL,
`kind` SMALLINT DEFAULT 0, -- 1.20.0
`kind` SMALLINT DEFAULT 0, -- 1.20.0
`category` INTEGER DEFAULT 0, -- 1.20.0
`name` VARCHAR(255) NOT NULL,
`website` VARCHAR(255),

View File

@ -47,7 +47,8 @@ class FreshRSS_Export_Service {
$view = new FreshRSS_View();
$day = date('Y-m-d');
$view->categories = $this->category_dao->listCategories(true);
$view->categories = $this->category_dao->listCategories(true, true);
$view->excludeMutedFeeds = false;
return [
"feeds_{$day}.opml.xml",

View File

@ -10,25 +10,36 @@ class FreshRSS_Import_Service {
/** @var FreshRSS_FeedDAO */
private $feedDAO;
/** @var bool true if success, false otherwise */
private $lastStatus;
/**
* Initialize the service for the given user.
*
* @param string $username
*/
public function __construct($username) {
public function __construct($username = null) {
require_once(LIB_PATH . '/lib_opml.php');
$this->catDAO = FreshRSS_Factory::createCategoryDao($username);
$this->feedDAO = FreshRSS_Factory::createFeedDao($username);
}
/** @return bool true if success, false otherwise */
public function lastStatus(): bool {
return $this->lastStatus;
}
/**
* This method parses and imports an OPML file.
*
* @param string $opml_file the OPML file content.
* @return boolean false if an error occurred, true otherwise.
* @param FreshRSS_Category|null $parent_cat the name of the parent category.
* @param boolean $flatten true to disable categories, false otherwise.
* @return array<FreshRSS_Category>|false an array of categories containing some feeds, or false if an error occurred.
*/
public function importOpml($opml_file) {
public function importOpml(string $opml_file, $parent_cat = null, $flatten = false, $dryRun = false) {
$this->lastStatus = true;
$opml_array = array();
try {
$opml_array = libopml_parse_string($opml_file, false);
@ -38,24 +49,22 @@ class FreshRSS_Import_Service {
} else {
Minz_Log::warning($e->getMessage());
}
$this->lastStatus = false;
return false;
}
$this->catDAO->checkDefault();
return $this->addOpmlElements($opml_array['body']);
return $this->addOpmlElements($opml_array['body'], $parent_cat, $flatten, $dryRun);
}
/**
* This method imports an OPML file based on its body.
*
* @param array $opml_elements an OPML element (body or outline).
* @param string $parent_cat the name of the parent category.
* @return boolean false if an error occurred, true otherwise.
* @param FreshRSS_Category|null $parent_cat the name of the parent category.
* @param boolean $flatten true to disable categories, false otherwise.
* @return array<FreshRSS_Category> an array of categories containing some feeds
*/
private function addOpmlElements($opml_elements, $parent_cat = null) {
$isOkStatus = true;
private function addOpmlElements($opml_elements, $parent_cat = null, $flatten = false, $dryRun = false) {
$nb_feeds = count($this->feedDAO->listFeeds());
$nb_cats = count($this->catDAO->listCategories(false));
$limits = FreshRSS_Context::$system_conf->limits;
@ -67,64 +76,61 @@ class FreshRSS_Import_Service {
(isset($b['xmlUrl']) ? 'Z' : 'A') . (isset($b['text']) ? $b['text'] : ''));
});
$categories = [];
foreach ($opml_elements as $elt) {
if (isset($elt['xmlUrl'])) {
// If xmlUrl exists, it means it is a feed
if (FreshRSS_Context::$isCli && $nb_feeds >= $limits['max_feeds']) {
Minz_Log::warning(_t('feedback.sub.feed.over_max',
$limits['max_feeds']));
$isOkStatus = false;
$this->lastStatus = false;
continue;
}
if ($this->addFeedOpml($elt, $parent_cat)) {
if ($this->addFeedOpml($elt, $parent_cat, $dryRun)) {
$nb_feeds++;
} else {
$isOkStatus = false;
$this->lastStatus = false;
}
} elseif (!empty($elt['text'])) {
// No xmlUrl? It should be a category!
$limit_reached = ($nb_cats >= $limits['max_categories']);
$limit_reached = !$flatten && ($nb_cats >= $limits['max_categories']);
if (!FreshRSS_Context::$isCli && $limit_reached) {
Minz_Log::warning(_t('feedback.sub.category.over_max',
$limits['max_categories']));
$isOkStatus = false;
continue;
$this->lastStatus = false;
$flatten = true;
}
if ($this->addCategoryOpml($elt, $parent_cat, $limit_reached)) {
$category = $this->addCategoryOpml($elt, $parent_cat, $flatten, $dryRun);
if ($category) {
$nb_cats++;
} else {
$isOkStatus = false;
$categories[] = $category;
}
}
}
return $isOkStatus;
return $categories;
}
/**
* This method imports an OPML feed element.
*
* @param array $feed_elt an OPML element (must be a feed element).
* @param string $parent_cat the name of the parent category.
* @return boolean false if an error occurred, true otherwise.
* @param FreshRSS_Category|null $parent_cat the name of the parent category.
* @return FreshRSS_Feed|null a feed.
*/
private function addFeedOpml($feed_elt, $parent_cat) {
private function addFeedOpml($feed_elt, $parent_cat, $dryRun = false) {
if ($parent_cat == null) {
// This feed has no parent category so we get the default one
$this->catDAO->checkDefault();
$default_cat = $this->catDAO->getDefault();
$parent_cat = $default_cat->name();
}
$cat = $this->catDAO->searchByName($parent_cat);
if ($cat == null) {
// If there is not $cat, it means parent category does not exist in
// database.
// If it happens, take the default category.
$this->catDAO->checkDefault();
$cat = $this->catDAO->getDefault();
$parent_cat = $this->catDAO->getDefault();
if ($parent_cat == null) {
$this->lastStatus = false;
return null;
}
}
// We get different useful information
@ -139,11 +145,11 @@ class FreshRSS_Import_Service {
$description = Minz_Helper::htmlspecialchars_utf8($feed_elt['description']);
}
$error = false;
try {
// Create a Feed object and add it in DB
$feed = new FreshRSS_Feed($url);
$feed->_category($cat->id());
$feed->_category($parent_cat->id());
$parent_cat->addFeed($feed);
$feed->_name($name);
$feed->_website($website);
$feed->_description($description);
@ -180,14 +186,20 @@ class FreshRSS_Import_Service {
}
// Call the extension hook
/** @var FreshRSS_Feed|null */
$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
if ($dryRun) {
return $feed;
}
if ($feed != null) {
// addFeedObject checks if feed is already in DB so nothing else to
// check here
// addFeedObject checks if feed is already in DB
$id = $this->feedDAO->addFeedObject($feed);
$error = ($id == false);
} else {
$error = true;
if ($id == false) {
$this->lastStatus = false;
} else {
$feed->_id($id);
return $feed;
}
}
} catch (FreshRSS_Feed_Exception $e) {
if (FreshRSS_Context::$isCli) {
@ -195,54 +207,76 @@ class FreshRSS_Import_Service {
} else {
Minz_Log::warning($e->getMessage());
}
$error = true;
$this->lastStatus = false;
}
if ($error) {
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id() . "\n");
} else {
Minz_Log::warning('Error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id());
}
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' .
SimplePie_Misc::url_remove_credentials($url) . ' in category ' . $parent_cat->id() . "\n");
} else {
Minz_Log::warning('Error during OPML feed import from URL: ' .
SimplePie_Misc::url_remove_credentials($url) . ' in category ' . $parent_cat->id());
}
return !$error;
return null;
}
/**
* This method imports an OPML category element.
*
* @param array $cat_elt an OPML element (must be a category element).
* @param string $parent_cat the name of the parent category.
* @param boolean $cat_limit_reached indicates if category limit has been reached.
* if yes, category is not added (but we try for feeds!)
* @return boolean false if an error occurred, true otherwise.
* @param FreshRSS_Category|null $parent_cat the name of the parent category.
* @param boolean $flatten true to disable categories, false otherwise.
* @return FreshRSS_Category|null a new category containing some feeds, or null if no category was created, or false if an error occurred.
*/
private function addCategoryOpml($cat_elt, $parent_cat, $cat_limit_reached) {
// Create a new Category object
$catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']);
$cat = new FreshRSS_Category($catName);
private function addCategoryOpml($cat_elt, $parent_cat, $flatten = false, $dryRun = false) {
$error = false;
$cat = null;
if (!$flatten) {
$catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']);
$cat = new FreshRSS_Category($catName);
$error = true;
if (FreshRSS_Context::$isCli || !$cat_limit_reached) {
$id = $this->catDAO->addCategoryObject($cat);
$error = ($id === false);
}
if ($error) {
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n");
foreach ($cat_elt as $key => $value) {
if (is_array($value) && !empty($value['value']) && ($value['namespace'] ?? '') === FreshRSS_Export_Service::FRSS_NAMESPACE) {
switch ($key) {
case 'opmlUrl':
$opml_url = checkUrl($value['value']);
if ($opml_url != '') {
$cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
$cat->_attributes('opml_url', $opml_url);
}
break;
}
}
}
if (!$dryRun) {
$id = $this->catDAO->addCategoryObject($cat);
if ($id == false) {
$this->lastStatus = false;
$error = true;
} else {
$cat->_id($id);
}
}
if ($error) {
if (FreshRSS_Context::$isCli) {
fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n");
} else {
Minz_Log::warning('Error during OPML category import from URL: ' . $catName);
}
} else {
Minz_Log::warning('Error during OPML category import from URL: ' . $catName);
$parent_cat = $cat;
}
}
if (isset($cat_elt['@outlines'])) {
// Our cat_elt contains more categories or more feeds, so we
// add them recursively.
// Note: FreshRSS does not support yet category arborescence
$error &= !$this->addOpmlElements($cat_elt['@outlines'], $catName);
// Note: FreshRSS does not support yet category arborescence, so always flatten from here
$this->addOpmlElements($cat_elt['@outlines'], $parent_cat, true, $dryRun);
}
return !$error;
return $cat;
}
}

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Jít zpět na vaše kanály RSS',
'cancel' => 'Zrušit',
'create' => 'Vytvořit',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Snížit úroveň',
'disable' => 'Zakázat',
'empty' => 'Vyprázdnit',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Zvýšit úroveň',
'purge' => 'Vymazat',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Odebrat',
'rename' => 'Přejmenovat',
'see_website' => 'Zobrazit webovou stránku',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Kategorie',
'add' => 'Přidat kategorii',
'archiving' => 'Archivace',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Vyprázdit kategorii',
'information' => 'Informace',
'opml_url' => 'OPML URL', // TODO
'position' => 'Zobrazit pozici',
'position_help' => 'Pro ovládání pořadí řazení kategorií',
'title' => 'Název',
@ -181,6 +186,7 @@ return array(
'_' => 'Správa odběrů',
'add' => 'Přidat kanál nebo kategorii',
'add_category' => 'Přidat kategorii',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Přidat kanál',
'add_label' => 'Přidat popisek',
'delete_label' => 'Odstranit popisek',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Zurück zu Ihren RSS-Feeds gehen',
'cancel' => 'Abbrechen',
'create' => 'Erstellen',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Zurückstufen',
'disable' => 'Deaktivieren',
'empty' => 'Leeren',
@ -31,6 +32,7 @@ return array(
'open_url' => 'URL öffnen',
'promote' => 'Hochstufen',
'purge' => 'Bereinigen',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Entfernen',
'rename' => 'Umbenennen',
'see_website' => 'Website ansehen',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Kategorie',
'add' => 'Kategorie hinzufügen',
'archiving' => 'Archivierung',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Leere Kategorie',
'information' => 'Information', // IGNORE
'opml_url' => 'OPML URL', // TODO
'position' => 'Reihenfolge',
'position_help' => 'Steuert die Kategoriesortierung',
'title' => 'Titel',
@ -181,6 +186,7 @@ return array(
'_' => 'Abonnementverwaltung',
'add' => 'Feed oder Kategorie hinzufügen',
'add_category' => 'Kategorie hinzufügen',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Feed hinzufügen',
'add_label' => 'Label hinzufügen',
'delete_label' => 'Label löschen',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Go back to your RSS feeds', // IGNORE
'cancel' => 'Cancel', // IGNORE
'create' => 'Create', // IGNORE
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Demote', // IGNORE
'disable' => 'Disable', // IGNORE
'empty' => 'Empty', // IGNORE
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // IGNORE
'promote' => 'Promote', // IGNORE
'purge' => 'Purge', // IGNORE
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Remove', // IGNORE
'rename' => 'Rename', // IGNORE
'see_website' => 'See website', // IGNORE

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Category', // IGNORE
'add' => 'Add a category', // IGNORE
'archiving' => 'Archiving', // IGNORE
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Empty category', // IGNORE
'information' => 'Information', // IGNORE
'opml_url' => 'OPML URL', // TODO
'position' => 'Display position', // IGNORE
'position_help' => 'To control category sort order', // IGNORE
'title' => 'Title', // IGNORE
@ -181,6 +186,7 @@ return array(
'_' => 'Subscription management', // IGNORE
'add' => 'Add a feed or category', // IGNORE
'add_category' => 'Add a category', // IGNORE
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Add a feed', // IGNORE
'add_label' => 'Add a label', // IGNORE
'delete_label' => 'Delete a label', // IGNORE

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Go back to your RSS feeds',
'cancel' => 'Cancel',
'create' => 'Create',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Demote',
'disable' => 'Disable',
'empty' => 'Empty',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL',
'promote' => 'Promote',
'purge' => 'Purge',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Remove',
'rename' => 'Rename',
'see_website' => 'See website',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Category',
'add' => 'Add a category',
'archiving' => 'Archiving',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Empty category',
'information' => 'Information',
'opml_url' => 'OPML URL', // TODO
'position' => 'Display position',
'position_help' => 'To control category sort order',
'title' => 'Title',
@ -181,6 +186,7 @@ return array(
'_' => 'Subscription management',
'add' => 'Add a feed or category',
'add_category' => 'Add a category',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Add a feed',
'add_label' => 'Add a label',
'delete_label' => 'Delete a label',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← regresar a tus fuentes RSS',
'cancel' => 'Cancelar',
'create' => 'Crear',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Degradar',
'disable' => 'Desactivar',
'empty' => 'Vaciar',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Promover',
'purge' => 'Eliminar',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Borrar',
'rename' => 'Cambiar el nombre a',
'see_website' => 'Ver web',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Categoría',
'add' => 'Añadir categoría',
'archiving' => 'Archivo',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Vaciar categoría',
'information' => 'Información',
'opml_url' => 'OPML URL', // TODO
'position' => 'Posición de visualización',
'position_help' => 'Para controlar el orden de clasificación de categorías',
'title' => 'Título',
@ -181,6 +186,7 @@ return array(
'_' => 'Administración de suscripciones',
'add' => 'Agregar un feed o una categoría',
'add_category' => 'Agregar una categoría',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Añadir un feed',
'add_label' => 'Añadir una etiqueta',
'delete_label' => 'Eliminar una etiqueta',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Retour à vos flux RSS',
'cancel' => 'Annuler',
'create' => 'Créer',
'delete_muted_feeds' => 'Supprimer les flux désactivés',
'demote' => 'Rétrograder',
'disable' => 'Désactiver',
'empty' => 'Vider',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Ouvrir lURL',
'promote' => 'Promouvoir',
'purge' => 'Purger',
'refresh_opml' => 'Rafraîchir OPML',
'remove' => 'Supprimer',
'rename' => 'Renommer',
'see_website' => 'Voir le site',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Catégorie',
'add' => 'Ajouter catégorie',
'archiving' => 'Archivage',
'dynamic_opml' => array(
'_' => 'OPML dynamique',
'help' => 'Fournir lURL dun <a href=http://opml.org/ target=_blank>fichier OPML</a> qui donnera dynamiquement la liste des flux de cette catégorie',
),
'empty' => 'Catégorie vide',
'information' => 'Informations',
'opml_url' => 'URL de lOPML',
'position' => 'Position daffichage',
'position_help' => 'Pour contrôler lordre de tri des catégories',
'title' => 'Titre',
@ -112,7 +117,7 @@ return array(
'title' => 'Maintenance', // IGNORE
),
'moved_category_deleted' => 'Lors de la suppression dune catégorie, ses flux seront automatiquement classés dans <em>%s</em>.',
'mute' => 'muet',
'mute' => 'désactivé',
'no_selected' => 'Aucun flux sélectionné.',
'number_entries' => '%d articles', // IGNORE
'priority' => array(
@ -181,6 +186,7 @@ return array(
'_' => 'Gestion des abonnements',
'add' => 'Ajouter un flux/une catégorie',
'add_category' => 'Ajouter une catégorie',
'add_dynamic_opml' => 'Ajouter un OPML dynamique',
'add_feed' => 'Ajouter un flux',
'add_label' => 'Ajouter une étiquette',
'delete_label' => 'Supprimer une étiquette',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← חזרה להזנות הRSS שלך',
'cancel' => 'ביטול',
'create' => 'יצירה',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Demote', // TODO
'disable' => 'Disable', // TODO
'empty' => 'Empty', // TODO
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Promote', // TODO
'purge' => 'Purge', // TODO
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Remove', // TODO
'rename' => 'Rename', // TODO
'see_website' => 'ראו אתר',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'קטגוריה',
'add' => 'Add a category', // TODO
'archiving' => 'ארכוב',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Empty category', // TODO
'information' => 'מידע',
'opml_url' => 'OPML URL', // TODO
'position' => 'Display position', // TODO
'position_help' => 'To control category sort order', // TODO
'title' => 'כותרת',
@ -181,6 +186,7 @@ return array(
'_' => 'ניהול הרשמות',
'add' => 'Add a feed or category', // TODO
'add_category' => 'Add a category', // TODO
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Add a feed', // TODO
'add_label' => 'Add a label', // TODO
'delete_label' => 'Delete a label', // TODO

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Indietro',
'cancel' => 'Annulla',
'create' => 'Crea',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Demote', // TODO
'disable' => 'Disabilita',
'empty' => 'Vuoto',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Promote', // TODO
'purge' => 'Purge', // TODO
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Rimuovi',
'rename' => 'Rename', // TODO
'see_website' => 'Vai al sito',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Categoria',
'add' => 'Aggiungi categoria',
'archiving' => 'Archiviazione',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Categoria vuota',
'information' => 'Informazioni',
'opml_url' => 'OPML URL', // TODO
'position' => 'Display position', // TODO
'position_help' => 'To control category sort order', // TODO
'title' => 'Titolo',
@ -181,6 +186,7 @@ return array(
'_' => 'Gestione sottoscrizioni',
'add' => 'Add a feed or category', // TODO
'add_category' => 'Add a category', // TODO
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Add a feed', // TODO
'add_label' => 'Add a label', // TODO
'delete_label' => 'Delete a label', // TODO

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← RSSフィードに戻る',
'cancel' => 'キャンセル',
'create' => '作成',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => '寄付',
'disable' => '無効',
'empty' => '空',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'プロモート',
'purge' => '不要なデータの削除',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => '消去',
'rename' => 'リネーム',
'see_website' => 'webサイトを閲覧してください',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'カテゴリ',
'add' => 'カテゴリを追加する',
'archiving' => 'アーカイブ',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'からのカテゴリ',
'information' => 'インフォメーション',
'opml_url' => 'OPML URL', // TODO
'position' => '表示位置',
'position_help' => 'カテゴリの表示順を操作する',
'title' => 'タイトル',
@ -181,6 +186,7 @@ return array(
'_' => '購読されたものの管理',
'add' => 'フィードあるいはカテゴリを追加します',
'add_category' => 'カテゴリの追加',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'フィードの追加',
'add_label' => 'ラベルの追加',
'delete_label' => 'ラベルの削除',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← RSS 피드로 돌아가기',
'cancel' => '취소',
'create' => '생성',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => '목록 수준 내리기',
'disable' => '비활성화',
'empty' => '비우기',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => '목록 수준 올리기',
'purge' => '제거',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => '삭제',
'rename' => '이름 바꾸기',
'see_website' => '웹사이트 열기',

View File

@ -24,8 +24,13 @@ return array(
'_' => '카테고리',
'add' => '카테고리 추가',
'archiving' => '보관',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => '빈 카테고리',
'information' => '정보',
'opml_url' => 'OPML URL', // TODO
'position' => '표시 위치',
'position_help' => '정렬 순서 제어',
'title' => '제목',
@ -181,6 +186,7 @@ return array(
'_' => '구독 관리',
'add' => '피드 혹은 카테고리 추가',
'add_category' => '카테고리 추가',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => '피드 추가',
'add_label' => '라벨 추가',
'delete_label' => '라벨 삭제',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Ga terug naar je RSS feeds',
'cancel' => 'Annuleren',
'create' => 'Opslaan',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Degraderen',
'disable' => 'Uitzetten',
'empty' => 'Leeg',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Bevorderen',
'purge' => 'Zuiveren',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Verwijderen',
'rename' => 'Hernoemen',
'see_website' => 'Bekijk website',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Categorie',
'add' => 'Voeg categorie',
'archiving' => 'Archiveren',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Lege categorie',
'information' => 'Informatie',
'opml_url' => 'OPML URL', // TODO
'position' => 'Weergavepositie',
'position_help' => 'Om de categorieweergave-sorteervolgorde te controleren',
'title' => 'Titel',
@ -181,6 +186,7 @@ return array(
'_' => 'Abonnementenbeheer',
'add' => 'Feed of categorie toevoegen',
'add_category' => 'Categorie toevoegen',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Feed toevoegen',
'add_label' => 'Label toevoegen',
'delete_label' => 'Label verwijderen',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Tornar a vòstres fluxes RSS',
'cancel' => 'Anullar',
'create' => 'Crear',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Retrogradar',
'disable' => 'Desactivar',
'empty' => 'Voidar',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Promòure',
'purge' => 'Purgar',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Levar',
'rename' => 'Renomenar',
'see_website' => 'Veire lo site',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Categoria',
'add' => 'Ajustar categoria',
'archiving' => 'Archivar',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Categoria voida',
'information' => 'Informacions',
'opml_url' => 'OPML URL', // TODO
'position' => 'Mostrar la posicion',
'position_help' => 'Per contrarotlar lòrdre de tria de la categoria',
'title' => 'Títol',
@ -181,6 +186,7 @@ return array(
'_' => 'Gestion dels abonaments',
'add' => 'Apondon de flux o categoria',
'add_category' => 'Ajustar una categoria',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Ajustar un flux',
'add_label' => 'Ajustar una etiqueta',
'delete_label' => 'Suprimir una etiqueta',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Wróć do subskrybowanych kanałów RSS',
'cancel' => 'Anuluj',
'create' => 'Stwórz',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Zdegraduj',
'disable' => 'Wyłącz',
'empty' => 'Opróżnij',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Awansuj',
'purge' => 'Oczyść',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Usuń',
'rename' => 'Zmień nazwę',
'see_website' => 'Przejdź na stronę',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Kategoria',
'add' => 'Dodaj kategoria',
'archiving' => 'Archiwizacja',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Pusta kategoria',
'information' => 'Informacje',
'opml_url' => 'OPML URL', // TODO
'position' => 'Miejsce wyświetlania',
'position_help' => 'Kontrola porządku sortowania kategorii',
'title' => 'Tytuł',
@ -181,6 +186,7 @@ return array(
'_' => 'Zarządzanie subskrypcjami',
'add' => 'Dodaj kanał lub kategorię',
'add_category' => 'Dodaj kategorię',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Dodaj kanał',
'add_label' => 'Dodaj etykietę',
'delete_label' => 'Usuń etykietę',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Volte para o seu feeds RSS',
'cancel' => 'Cancelar',
'create' => 'Criar',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Despromover',
'disable' => 'Desabilitar',
'empty' => 'Vazio',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Promover',
'purge' => 'Limpar',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Remover',
'rename' => 'Renomear',
'see_website' => 'Ver o site',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Categoria',
'add' => 'Adicionar categoria',
'archiving' => 'Arquivar',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Categoria vazia',
'information' => 'Informações',
'opml_url' => 'OPML URL', // TODO
'position' => 'Posição de exibição',
'position_help' => 'Para controlar a ordem de exibição',
'title' => 'Título',
@ -181,6 +186,7 @@ return array(
'_' => 'Gerenciamento de inscrições',
'add' => 'Adicionar um feed ou categoria',
'add_category' => 'Adicionar uma categoria',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Adicionar um feed',
'add_label' => 'Adicionar uma etiqueta',
'delete_label' => 'Deletar uma etiqueta',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Вернуться к вашим RSS-лентам',
'cancel' => 'Отменить',
'create' => 'Создать',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Понизить',
'disable' => 'Отключить',
'empty' => 'Опустошить',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Открыть URL',
'promote' => 'Продвинуть',
'purge' => 'Запустить очистку',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Удалить',
'rename' => 'Переименовать',
'see_website' => 'Посмотреть на сайте',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Категория',
'add' => 'Добавить категория',
'archiving' => 'Архивирование',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Пустая категория',
'information' => 'Информация',
'opml_url' => 'OPML URL', // TODO
'position' => 'Положение отображения',
'position_help' => 'Влияет на порядок отображения категорий',
'title' => 'Заголовок',
@ -181,6 +186,7 @@ return array(
'_' => 'Управление подписками',
'add' => 'Добавить ленту или категорию',
'add_category' => 'Добавить категорию',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Добавить ленту',
'add_label' => 'Добавить метку',
'delete_label' => 'Удалить метку',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← Späť na vaše RSS kanály',
'cancel' => 'Zrušiť',
'create' => 'Vytvoriť',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Degradovať',
'disable' => 'Zakázať',
'empty' => 'Vyprázdniť',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Podporiť',
'purge' => 'Vymazať',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Odstrániť',
'rename' => 'Premenovať',
'see_website' => 'Zobraziť webovú stránku',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Kategória',
'add' => 'Pridať kategória',
'archiving' => 'Archív',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Prázdna kategória',
'information' => 'Informácia',
'opml_url' => 'OPML URL', // TODO
'position' => 'Zobrazť pozíciu',
'position_help' => 'Na kontrolu zoradenia kategórií',
'title' => 'Názov',
@ -181,6 +186,7 @@ return array(
'_' => 'Správa odoberaných kanálov',
'add' => 'Pridať kanál alebo kategóriu',
'add_category' => 'Pridať kategóriu',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Pridať kanál',
'add_label' => 'Pridať štítok',
'delete_label' => 'Zmazať štítok',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← RSS akışlarınız için geri gidin',
'cancel' => 'İptal',
'create' => 'Oluştur',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => 'Yöneticilikten al',
'disable' => 'Pasif',
'empty' => 'Boş',
@ -31,6 +32,7 @@ return array(
'open_url' => 'Open URL', // TODO
'promote' => 'Yöneticilik ata',
'purge' => 'Temizle',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => 'Sil',
'rename' => 'Yeniden adlandır',
'see_website' => 'Siteyi gör',

View File

@ -24,8 +24,13 @@ return array(
'_' => 'Kategori',
'add' => 'Kategori ekle',
'archiving' => 'Arşiv',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => 'Boş kategori',
'information' => 'Bilgi',
'opml_url' => 'OPML URL', // TODO
'position' => 'Konumu göster',
'position_help' => 'Kategori sıralama düzenini kontrol etmek için',
'title' => 'Başlık',
@ -181,6 +186,7 @@ return array(
'_' => 'Abonelik yönetimi',
'add' => 'Kategori veya akış ekle',
'add_category' => 'Kategori ekle',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => 'Akış ekle',
'add_label' => 'Etiket ekle',
'delete_label' => 'Etiket sil',

View File

@ -18,6 +18,7 @@ return array(
'back_to_rss_feeds' => '← 返回订阅源',
'cancel' => '取消',
'create' => '创建',
'delete_muted_feeds' => 'Delete muted feeds', // TODO
'demote' => '撤销管理员',
'disable' => '禁用',
'empty' => '清空',
@ -31,6 +32,7 @@ return array(
'open_url' => '打开链接',
'promote' => '设为管理员',
'purge' => '清理',
'refresh_opml' => 'Refresh OPML', // TODO
'remove' => '删除',
'rename' => '重命名',
'see_website' => '网站中查看',

View File

@ -24,8 +24,13 @@ return array(
'_' => '分类',
'add' => '添加分类',
'archiving' => '归档',
'dynamic_opml' => array(
'_' => 'Dynamic OPML', // TODO
'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO
),
'empty' => '空分类',
'information' => '信息',
'opml_url' => 'OPML URL', // TODO
'position' => '显示位置',
'position_help' => '控制分类排列顺序',
'title' => '标题',
@ -181,6 +186,7 @@ return array(
'_' => '订阅管理',
'add' => '添加订阅源或分类',
'add_category' => '添加分类',
'add_dynamic_opml' => 'Add dynamic OPML', // TODO
'add_feed' => '添加订阅源',
'add_label' => '添加标签',
'delete_label' => '删除标签',

View File

@ -89,7 +89,9 @@
<div class="tree-folder-title">
<a class="dropdown-toggle" href="#"><?= _i($c_show ? 'up' : 'down') ?></a>
<a class="title<?= $cat->hasFeedsWithError() ? ' error' : '' ?>" data-unread="<?=
format_number($cat->nbNotRead()) ?>" href="<?= _url('index', $actual_view, 'get', 'c_' . $cat->id()) . $state_filter_manual ?>"><?= $cat->name() ?></a>
format_number($cat->nbNotRead()) ?>" href="<?= _url('index', $actual_view, 'get', 'c_' . $cat->id()) . $state_filter_manual ?>"><?=
$cat->name()
?><?php if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo _i('opml-dyn'); } ?></a>
</div>
<ul class="tree-folder-items<?= $c_show ? ' active' : '' ?>">

View File

@ -2,7 +2,7 @@
<div class="item title">
<a href="<?= _url('index', 'index') ?>">
<?php if (FreshRSS_Context::$system_conf->logo_html == '') { ?>
<img class="logo" src="<?= _i('FreshRSS-logo', true) ?>" alt="FreshRSS" />
<img class="logo" src="<?= _i('FreshRSS-logo', FreshRSS_Themes::ICON_URL) ?>" alt="FreshRSS" />
<?php
} else {
echo FreshRSS_Context::$system_conf->logo_html;

View File

@ -34,11 +34,18 @@ if (_t('gen.dir') === 'rtl') {
if ($this->rss_title != '') {
$url_rss = $url_base;
$url_rss['a'] = 'rss';
unset($url_rss['params']['rid']);
if (FreshRSS_Context::$user_conf->since_hours_posts_per_rss) {
$url_rss['params']['hours'] = FreshRSS_Context::$user_conf->since_hours_posts_per_rss;
}
?>
<link rel="alternate" type="application/rss+xml" title="<?= $this->rss_title ?>" href="<?= Minz_Url::display($url_rss) ?>" />
<?php } if (FreshRSS_Context::isAll() || FreshRSS_Context::isCategory() || FreshRSS_Context::isFeed()) {
$opml_rss = $url_base;
$opml_rss['a'] = 'opml';
unset($opml_rss['params']['rid']);
?>
<link rel="outline" type="text/x-opml" title="OPML" href="<?= Minz_Url::display($opml_rss) ?>" />
<?php } if (FreshRSS_Context::$system_conf->allow_robots) { ?>
<meta name="description" content="<?= htmlspecialchars(FreshRSS_Context::$name . ' | ' . FreshRSS_Context::$description, ENT_COMPAT, 'UTF-8') ?>" />
<?php } else { ?>

View File

@ -31,7 +31,7 @@
<div class="item title">
<a href="<?= _url('index', 'index') ?>">
<?php if (FreshRSS_Context::$system_conf->logo_html == '') { ?>
<img class="logo" src="<?= _i('FreshRSS-logo', true) ?>" alt="FreshRSS" />
<img class="logo" src="<?= _i('FreshRSS-logo', FreshRSS_Themes::ICON_URL) ?>" alt="FreshRSS" />
<?php
} else {
echo FreshRSS_Context::$system_conf->logo_html;

View File

@ -0,0 +1 @@
OK

View File

@ -0,0 +1 @@
OK

View File

@ -1,6 +1,9 @@
<?php /** @var FreshRSS_View $this */ ?>
<div class="post">
<h2><?= $this->category->name() ?></h2>
<h2>
<?= $this->category->name() ?>
<?php if ($this->category->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo _i('opml-dyn'); } ?>
</h2>
<div>
<a href="<?= _url('index', 'index', 'get', 'c_' . $this->category->id()) ?>"><?= _i('link') ?> <?= _t('gen.action.filter') ?></a>
@ -31,10 +34,37 @@
<div class="group-controls">
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
</div>
</div>
<?php if (!$this->category->isDefault()): ?>
<legend><?= _t('sub.category.dynamic_opml') ?> <?= _i('opml-dyn') ?></legend>
<div class="form-group">
<label class="group-name" for="opml_url"><?= _t('sub.category.opml_url') ?></label>
<div class="group-controls">
<div class="stick">
<input id="opml_url" name="opml_url" type="url" autocomplete="off" class="long" data-disable-update="refreshOpml" value="<?= $this->category->attributes('opml_url') ?>" />
<button type="submit" class="btn" id="refreshOpml" formmethod="post" formaction="<?= _url('category', 'refreshOpml', 'id', $this->category->id()) ?>">
<?= _i('refresh') ?> <?= _t('gen.action.refresh_opml') ?>
</button>
<a class="btn open-url" target="_blank" rel="noreferrer" href="" data-input="opml_url" title="<?= _t('gen.action.open_url') ?>"><?= _i('link') ?></a>
</div>
<p class="help"><?= _i('help') ?> <?= _t('gen.short.blank_to_disable') ?></p>
<p class="help"><?= _i('help') ?> <?= _t('sub.category.dynamic_opml.help') ?></p>
</div>
</div>
<div class="form-group form-actions">
<div class="group-controls">
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
<button type="submit" class="btn btn-attention confirm"
data-str-confirm="<?= _t('gen.js.confirm_action_feed_cat') ?>"
formaction="<?= _url('category', 'empty', 'id', $this->category->id(), 'muted', 1) ?>"
formmethod="post"><?= _t('gen.action.delete_muted_feeds') ?></button>
</div>
</div>
<?php endif; ?>
<legend><?= _t('sub.category.archiving') ?></legend>
<?php
$archiving = $this->category->attributes('archiving');

View File

@ -1,21 +1,14 @@
<?php
/** @var FreshRSS_View $this */
$opml_array = array(
'head' => array(
'title' => FreshRSS_Context::$system_conf->title,
'dateCreated' => date('D, d M Y H:i:s')
),
'body' => array()
);
foreach ($this->categories as $key => $cat) {
$opml_array['body'][$key] = array(
'text' => htmlspecialchars_decode($cat->name(), ENT_QUOTES),
'@outlines' => array()
);
foreach ($cat->feeds() as $feed) {
/**
* @param array<FreshRSS_Feed> $feeds
*/
function feedsToOutlines($feeds, $excludeMutedFeeds = false): array {
$outlines = [];
foreach ($feeds as $feed) {
if ($feed->mute() && $excludeMutedFeeds) {
continue;
}
$outline = [
'text' => htmlspecialchars_decode($feed->name(), ENT_QUOTES),
'type' => FreshRSS_Export_Service::TYPE_RSS_ATOM,
@ -47,8 +40,36 @@ foreach ($this->categories as $key => $cat) {
if ($feed->pathEntries() != '') {
$outline['frss:cssFullContent'] = ['namespace' => FreshRSS_Export_Service::FRSS_NAMESPACE, 'value' => $feed->pathEntries()];
}
$opml_array['body'][$key]['@outlines'][] = $outline;
$outlines[] = $outline;
}
return $outlines;
}
/** @var FreshRSS_View $this */
$opml_array = array(
'head' => array(
'title' => FreshRSS_Context::$system_conf->title,
'dateCreated' => date('D, d M Y H:i:s')
),
'body' => array()
);
if (!empty($this->categories)) {
foreach ($this->categories as $key => $cat) {
$outline = [
'text' => htmlspecialchars_decode($cat->name(), ENT_QUOTES),
'@outlines' => feedsToOutlines($cat->feeds(), $this->excludeMutedFeeds),
];
if ($cat->kind() === FreshRSS_Category::KIND_DYNAMIC_OPML) {
$outline['frss:opmlUrl'] = ['namespace' => FreshRSS_Export_Service::FRSS_NAMESPACE, 'value' => $cat->attributes('opml_url')];;
}
$opml_array['body'][$key] = $outline;
}
}
if (!empty($this->feeds)) {
$opml_array['body'][] = feedsToOutlines($this->feeds, $this->excludeMutedFeeds);
}
echo libopml_render($opml_array);

View File

@ -10,6 +10,15 @@
<h1><?= _t('sub.menu.import_export') ?></h1>
<h2><?= _t('sub.category.dynamic_opml') ?></h2>
<div class="form-group form-actions">
<div class="group-controls">
<ul>
<li><a href="<?= _url('subscription', 'add') ?>"><?= _t('sub.title.add_dynamic_opml') ?> <?= _i('opml-dyn') ?></a></li>
</ul>
</div>
</div>
<h2><?= _t('sub.import_export.import') ?></h2>
<form method="post" action="<?= _url('importExport', 'import') ?>" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />

View File

@ -0,0 +1,3 @@
<?php
/** @var FreshRSS_View $this */
$this->renderHelper('export/opml');

View File

@ -1,5 +1,14 @@
<?php /** @var FreshRSS_View $this */ ?>
<?php
/** @var FreshRSS_View $this */
$categories = [];
foreach ($this->categories as $category) {
$categories[] = [
'url' => Minz_Url::display(array('c' => 'category', 'a' => 'refreshOpml', 'params' => array('id' => $category->id(), 'ajax' => '1')), 'php'),
'title' => $category->name(),
];
}
$feeds = array();
foreach ($this->feeds as $feed) {
$feeds[] = array(
@ -8,6 +17,7 @@ foreach ($this->feeds as $feed) {
);
}
echo json_encode(array(
'categories' => $categories,
'feeds' => $feeds,
'feedback_no_refresh' => _t('feedback.sub.feed.no_refresh'),
'feedback_actualize' => _t('feedback.sub.actualize'),

View File

@ -15,7 +15,7 @@
<div class="form-group">
<label class="group-name" for="new-category"><?= _t('sub.category') ?></label>
<div class="group-controls">
<input id="new-category" name="new-category" type="text" autocomplete="off"/>
<input id="new-category" name="new-category" type="text" required="required" autocomplete="off" />
</div>
</div>
@ -45,7 +45,12 @@
<label class="group-name" for="category"><?= _t('sub.category') ?></label>
<div class="group-controls">
<select name="category" id="category">
<?php foreach ($this->categories as $cat) { ?>
<?php
foreach ($this->categories as $cat) {
if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) {
continue;
}
?>
<option value="<?= $cat->id() ?>"<?= $cat->id() == ( Minz_Request::param('cat_id') ?: 1 ) ? ' selected="selected"' : '' ?>>
<?= $cat->name() ?>
</option>
@ -218,4 +223,35 @@
</div>
</div>
</form>
<h2>
<?= _t('sub.title.add_dynamic_opml') ?>
<?= _i('opml-dyn') ?>
</h2>
<form action="<?= _url('category', 'create') ?>" method="post">
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
<div class="form-group">
<label class="group-name" for="new-category"><?= _t('sub.category') ?></label>
<div class="group-controls">
<input id="new-category" name="new-category" type="text" required="required" autocomplete="off" />
</div>
</div>
<div class="form-group">
<label class="group-name" for="opml_url"><?= _t('sub.category.opml_url') ?></label>
<div class="group-controls">
<div class="stick">
<input id="opml_url" name="opml_url" type="url" required="required" autocomplete="off" class="long" />
<a class="btn open-url" target="_blank" rel="noreferrer" href="" data-input="opml_url" title="<?= _t('gen.action.open_url') ?>"><?= _i('link') ?></a>
</div>
<p class="help"><?= _i('help') ?> <?= _t('sub.category.dynamic_opml.help') ?></p>
</div>
</div>
<div class="form-group form-actions">
<div class="group-controls">
<button type="submit" class="btn btn-important"><?= _t('gen.action.add') ?></button>
</div>
</div>
</form>
</main>

View File

@ -36,6 +36,7 @@
<div class="box-title">
<a class="configure open-slider" href="<?= _url('subscription', 'category', 'id', $cat->id()) ?>"><?= _i('configure') ?></a>
<?= $cat->name() ?>
<?php if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo _i('opml-dyn'); } ?>
</div>
<ul class="box-content drop-zone" dropzone="move" data-cat-id="<?= $cat->id() ?>">
<?php
@ -60,7 +61,9 @@
?>
<li class="item feed disabled"><div class="alert-warn"><?= _t('sub.category.empty') ?></div></li>
<?php } ?>
<li class="item feed"><a href="<?= _url('subscription', 'add', 'cat_id', $cat->id()) ?>"><?= _t('sub.feed.add') ?></a></li>
<?php if ($cat->kind() != FreshRSS_Category::KIND_DYNAMIC_OPML): ?>
<li class="item feed"><a href="<?= _url('subscription', 'add', 'cat_id', $cat->id()) ?>"><?= _t('sub.feed.add') ?></a></li>
<?php endif; ?>
</ul>
</div>
<?php } ?>

View File

@ -18,6 +18,16 @@ $username = cliInitUser($options['user']);
fwrite(STDERR, 'FreshRSS actualizing user “' . $username . "”…\n");
$result = FreshRSS_category_Controller::refreshDynamicOpmls();
if (!empty($result['errors'])) {
$errors = $result['errors'];
fwrite(STDERR, "FreshRSS error refreshing $errors dynamic OPMLs!\n");
}
if (!empty($result['successes'])) {
$successes = $result['successes'];
echo "FreshRSS refreshed $successes dynamic OPMLs for $username\n";
}
list($nbUpdatedFeeds, $feed, $nbNewArticles) = FreshRSS_feed_Controller::actualizeFeed(0, '', true);
echo "FreshRSS actualized $nbUpdatedFeeds feeds for $username ($nbNewArticles new articles)\n";

View File

@ -16,6 +16,7 @@ return array (
'keep_unreads' => false,
],
'ttl_default' => 3600,
'dynamic_opml_ttl_default' => 43200,
'mail_login' => '',
'email_validation_token' => '',
'token' => '',

View File

@ -1,3 +1,4 @@
*.spc
*.html
*.xml
!index.html

View File

@ -46,6 +46,10 @@ The following attributes are using similar naming conventions than [RSS-Bridge](
* Example: `div.main`
* `frss:filtersActionRead`: List (separated by a new line) of search queries to automatically mark a new article as read.
### Dynamic OPML (reading lists)
* `frss:opmlUrl`: If non-empty, indicates that this outline (category) should be dynamically populated from a remote OPML at the specified URL.
### Example
```xml

View File

@ -377,19 +377,19 @@ function enforceHttpEncoding(string $html, string $contentType = ''): string {
}
/**
* @param string $type {html,opml}
* @param array<string,mixed> $attributes
*/
function getHtml(string $url, array $attributes = []): string {
function httpGet(string $url, string $cachePath, string $type = 'html', array $attributes = []): string {
$limits = FreshRSS_Context::$system_conf->limits;
$feed_timeout = empty($attributes['timeout']) ? 0 : intval($attributes['timeout']);
$cachePath = FreshRSS_Feed::cacheFilename($url, $attributes, FreshRSS_Feed::KIND_HTML_XPATH);
$cacheMtime = @filemtime($cachePath);
if ($cacheMtime !== false && $cacheMtime > time() - intval($limits['cache_duration'])) {
$html = @file_get_contents($cachePath);
if ($html != '') {
$body = @file_get_contents($cachePath);
if ($body != '') {
syslog(LOG_DEBUG, 'FreshRSS uses cache for ' . SimplePie_Misc::url_remove_credentials($url));
return $html;
return $body;
}
}
@ -398,14 +398,25 @@ function getHtml(string $url, array $attributes = []): string {
}
if (FreshRSS_Context::$system_conf->simplepie_syslog_enabled) {
syslog(LOG_INFO, 'FreshRSS GET ' . SimplePie_Misc::url_remove_credentials($url));
syslog(LOG_INFO, 'FreshRSS GET ' . $type . ' ' . SimplePie_Misc::url_remove_credentials($url));
}
$accept = '*/*;q=0.8';
switch ($type) {
case 'opml':
$accept = 'text/x-opml,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8';
break;
case 'html':
default:
$accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
break;
}
// TODO: Implement HTTP 1.1 conditional GET If-Modified-Since
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_HTTPHEADER => array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'),
CURLOPT_HTTPHEADER => array('Accept: ' . $accept),
CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'],
CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'],
@ -428,27 +439,28 @@ function getHtml(string $url, array $attributes = []): string {
curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1');
}
}
$html = curl_exec($ch);
$body = curl_exec($ch);
$c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$c_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); //TODO: Check if that may be null
$c_error = curl_error($ch);
curl_close($ch);
if ($c_status != 200 || $c_error != '' || $html === false) {
if ($c_status != 200 || $c_error != '' || $body === false) {
Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url);
$body = '';
// TODO: Implement HTTP 410 Gone
}
if ($html == false) {
$html = '';
if ($body == false) {
$body = '';
} else {
$html = enforceHttpEncoding($html, $c_content_type);
$body = enforceHttpEncoding($body, $c_content_type);
}
if (file_put_contents($cachePath, $html) === false) {
if (file_put_contents($cachePath, $body) === false) {
Minz_Log::warning("Error saving cache $cachePath for $url");
}
return $html;
return $body;
}
/**
@ -770,8 +782,8 @@ function remove_query_by_get($get, $queries) {
return $final_queries;
}
function _i($icon, $url_only = false) {
return FreshRSS_Themes::icon($icon, $url_only);
function _i(string $icon, int $type = FreshRSS_Themes::ICON_DEFAULT): string {
return FreshRSS_Themes::icon($icon, $type);
}

View File

@ -307,8 +307,8 @@ function subscriptionExport() {
function subscriptionImport($opml) {
$user = Minz_Session::param('currentUser', '_');
$importService = new FreshRSS_Import_Service($user);
$ok = $importService->importOpml($opml);
if ($ok) {
$importService->importOpml($opml);
if ($importService->lastStatus()) {
list($nbUpdatedFeeds, $feed, $nbNewArticles) = FreshRSS_feed_Controller::actualizeFeed(0, '', true);
invalidateHttpCache($user);
exit('OK');

View File

@ -202,8 +202,8 @@ function updateHref(ev) {
}
// set event listener on "show url" buttons
function init_url_observers() {
document.querySelectorAll('.open-url').forEach(function (btn) {
function init_url_observers(parent) {
parent.querySelectorAll('.open-url').forEach(function (btn) {
btn.addEventListener('mouseover', updateHref);
btn.addEventListener('click', updateHref);
});
@ -276,7 +276,6 @@ function init_extra_afterDOM() {
if (!['normal', 'global', 'reader'].includes(context.current_view)) {
init_crypto_form();
init_password_observers(document.body);
init_url_observers();
init_select_observers();
init_configuration_alert();
@ -284,8 +283,10 @@ function init_extra_afterDOM() {
if (slider) {
init_slider(slider);
init_archiving(slider);
init_url_observers(slider);
} else {
init_archiving(document.body);
init_url_observers(document.body);
}
}

View File

@ -1,6 +1,6 @@
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
'use strict';
/* globals init_archiving, init_configuration_alert, init_password_observers, init_slider */
/* globals init_archiving, init_configuration_alert, init_password_observers, init_slider, init_url_observers */
// <popup>
let popup = null;
@ -64,6 +64,22 @@ function init_popup_preview_selector() {
});
}
/**
* Allow a <select class="select-show"> to hide/show elements defined by <option data-show="elem-id"></option>
*/
function init_disable_elements_on_update(parent) {
const inputs = parent.querySelectorAll('input[data-disable-update]');
for (const input of inputs) {
input.addEventListener('input', (e) => {
const elem = document.getElementById(e.target.dataset.disableUpdate);
if (elem) {
elem.disabled = true;
elem.remove();
}
});
}
}
/**
* Allow a <select class="select-show"> to hide/show elements defined by <option data-show="elem-id"></option>
*/
@ -120,7 +136,9 @@ function init_feed_afterDOM() {
init_popup();
init_popup_preview_selector();
init_select_show(slider);
init_disable_elements_on_update(slider);
init_password_observers(slider);
init_url_observers(slider);
init_valid_xpath(slider);
});
init_slider(slider);
@ -130,6 +148,7 @@ function init_feed_afterDOM() {
init_popup();
init_popup_preview_selector();
init_select_show(document.body);
init_disable_elements_on_update(document.body);
init_password_observers(document.body);
init_valid_xpath(document.body);
}

View File

@ -115,9 +115,10 @@ function incUnreadsFeed(article, feed_id, nb) {
}
// Update unread: category
elem = document.getElementById(feed_id).closest('.category');
feed_unreads = elem ? str2int(elem.getAttribute('data-unread')) : 0;
elem = document.getElementById(feed_id);
elem = elem ? elem.closest('.category') : null;
if (elem) {
feed_unreads = str2int(elem.getAttribute('data-unread'));
elem.setAttribute('data-unread', feed_unreads + nb);
elem = elem.querySelector('.title');
if (elem) {
@ -147,7 +148,7 @@ function incUnreadsFeed(article, feed_id, nb) {
// Update unread: title
document.title = document.title.replace(/^((?:\([\s0-9]+\) )?)/, function (m, p1) {
const feed = document.getElementById(feed_id);
if (article || feed.closest('.active')) {
if (article || (feed && feed.closest('.active'))) {
isCurrentView = true;
return incLabel(p1, nb, true);
} else if (document.querySelector('.all.active')) {
@ -1287,9 +1288,11 @@ function loadDynamicTags(div) {
}
// <actualize>
let feed_processed = 0;
let feeds_processed = 0;
let categories_processed = 0;
let to_process = 0;
function updateFeed(feeds, feeds_count) {
function refreshFeed(feeds, feeds_count) {
const feed = feeds.pop();
if (!feed) {
return;
@ -1297,14 +1300,15 @@ function updateFeed(feeds, feeds_count) {
const req = new XMLHttpRequest();
req.open('POST', feed.url, true);
req.onloadend = function (e) {
feeds_processed++;
if (this.status != 200) {
return badAjax(false);
badAjax(false);
} else {
const div = document.getElementById('actualizeProgress');
div.querySelector('.progress').innerHTML = (categories_processed + feeds_processed) + ' / ' + to_process;
div.querySelector('.title').innerHTML = feed.title;
}
feed_processed++;
const div = document.getElementById('actualizeProgress');
div.querySelector('.progress').innerHTML = feed_processed + ' / ' + feeds_count;
div.querySelector('.title').innerHTML = feed.title;
if (feed_processed === feeds_count) {
if (feeds_processed === feeds_count) {
// Empty request to commit new articles
const req2 = new XMLHttpRequest();
req2.open('POST', './?c=feed&a=actualize&id=-1&ajax=1', true);
@ -1317,7 +1321,7 @@ function updateFeed(feeds, feeds_count) {
noCommit: 0,
}));
} else {
updateFeed(feeds, feeds_count);
refreshFeed(feeds, feeds_count);
}
};
req.setRequestHeader('Content-Type', 'application/json');
@ -1327,8 +1331,73 @@ function updateFeed(feeds, feeds_count) {
}));
}
function refreshFeeds(json) {
feeds_processed = 0;
if (!json.feeds || json.feeds.length === 0) {
// Empty request to commit new articles
const req2 = new XMLHttpRequest();
req2.open('POST', './?c=feed&a=actualize&id=-1&ajax=1', true);
req2.onloadend = function (e) {
context.ajax_loading = false;
};
req2.setRequestHeader('Content-Type', 'application/json');
req2.send(JSON.stringify({
_csrf: context.csrf,
noCommit: 0,
}));
} else {
const feeds_count = json.feeds.length;
for (let i = 10; i > 0; i--) {
refreshFeed(json.feeds, feeds_count);
}
}
}
function refreshDynamicOpml(categories, categories_count, next) {
const category = categories.pop();
if (!category) {
return;
}
const req = new XMLHttpRequest();
req.open('POST', category.url, true);
req.onloadend = function (e) {
categories_processed++;
if (this.status != 200) {
badAjax(false);
} else {
const div = document.getElementById('actualizeProgress');
div.querySelector('.progress').innerHTML = (categories_processed + feeds_processed) + ' / ' + to_process;
div.querySelector('.title').innerHTML = category.title;
}
if (categories_processed === categories_count) {
if (next) { next(); }
} else {
refreshDynamicOpml(categories, categories_count, next);
}
};
req.setRequestHeader('Content-Type', 'application/json');
req.send(JSON.stringify({
_csrf: context.csrf,
noCommit: 1,
}));
}
function refreshDynamicOpmls(json, next) {
categories_processed = 0;
if (json.categories && json.categories.length > 0) {
const categories_count = json.categories.length;
for (let i = 10; i > 0; i--) {
refreshDynamicOpml(json.categories, categories_count, next);
}
} else {
if (next) { next(); }
}
}
function init_actualize() {
let auto = false;
let nbCategoriesFirstRound = 0;
let skipCategories = false;
const actualize = document.getElementById('actualize');
if (!actualize) {
@ -1352,33 +1421,29 @@ function init_actualize() {
if (!json) {
return badAjax(false);
}
if (auto && json.feeds.length < 1) {
if (auto && json.categories.length < 1 && json.feeds.length < 1) {
auto = false;
context.ajax_loading = false;
return false;
}
if (json.feeds.length === 0) {
openNotification(json.feedback_no_refresh, 'good');
// Empty request to commit new articles
const req2 = new XMLHttpRequest();
req2.open('POST', './?c=feed&a=actualize&id=-1&ajax=1', true);
req2.onloadend = function (e) {
context.ajax_loading = false;
};
req2.setRequestHeader('Content-Type', 'application/json');
req2.send(JSON.stringify({
_csrf: context.csrf,
noCommit: 0,
}));
return;
}
// Progress bar
const feeds_count = json.feeds.length;
document.body.insertAdjacentHTML('beforeend', '<div id="actualizeProgress" class="notification good">' +
to_process = json.categories.length + json.feeds.length + nbCategoriesFirstRound;
if (json.categories.length + json.feeds.length > 0 && !document.getElementById('actualizeProgress')) {
document.body.insertAdjacentHTML('beforeend', '<div id="actualizeProgress" class="notification good">' +
json.feedback_actualize + '<br /><span class="title">/</span><br /><span class="progress">0 / ' +
feeds_count + '</span></div>');
for (let i = 10; i > 0; i--) {
updateFeed(json.feeds, feeds_count);
to_process + '</span></div>');
} else {
openNotification(json.feedback_no_refresh, 'good');
}
if (json.categories.length > 0 && !skipCategories) {
skipCategories = true; // To avoid risk of infinite loop
nbCategoriesFirstRound = json.categories.length;
// If some dynamic OPML categories are refreshed, need to reload the list of feeds before updating them
refreshDynamicOpmls(json, () => {
context.ajax_loading = false;
actualize.click();
});
} else {
refreshFeeds(json);
}
};
req.setRequestHeader('Content-Type', 'application/json');

View File

@ -117,6 +117,7 @@ input:focus {
.icon[src*="/sort-up"],
.icon[src*="/sort-down"],
.icon[src*="/key"],
.icon[src*="/opml-dyn"],
.icon[src*="/configure"],
.icon[src*="/category"] {
/* Color light grey icons */

View File

@ -117,6 +117,7 @@ input:focus {
.icon[src*="/sort-up"],
.icon[src*="/sort-down"],
.icon[src*="/key"],
.icon[src*="/opml-dyn"],
.icon[src*="/configure"],
.icon[src*="/category"] {
/* Color light grey icons */

View File

@ -0,0 +1,8 @@
<svg width="93.619" height="93.619" viewBox="0 0 24.77 24.77" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(33.09 -98.68)">
<path style="fill:#000;stroke-width:.264583" d="M98.217 120.656V96.711H132.084v47.89H98.217Zm19.925 5.03c5.583-1.455 9.438-6.373 9.438-12.041 0-7.464-6.765-13.365-14.031-12.24-9.125 1.412-13.645 11.61-8.549 19.289 2.769 4.17 8.27 6.26 13.142 4.992zm-5.108-3.822c-1.295-.395-2.793-1.303-3.807-2.306-3.247-3.214-3.255-8.545-.018-11.837 1.622-1.65 3.547-2.412 6.074-2.402 2.59.009 4.158.678 5.999 2.558 1.733 1.77 2.423 3.685 2.272 6.297-.2 3.452-2.21 6.207-5.398 7.4-1.464.547-3.839.682-5.122.29zm3.713-5.181c.92-.476 1.578-1.634 1.578-2.774 0-2.794-3.33-4.117-5.331-2.117-.626.625-.754.977-.754 2.061 0 2.57 2.263 3.99 4.507 2.83z"/>
<path style="opacity:1;fill:#666;stroke:none;stroke-width:1.12498;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" d="M-20.705 98.68a12.385 12.385 0 0 0-12.384 12.385 12.385 12.385 0 0 0 12.384 12.385 12.385 12.385 0 0 0 12.386-12.385A12.385 12.385 0 0 0-20.705 98.68zm0 3.616a8.77 8.77 0 0 1 8.77 8.77 8.77 8.77 0 0 1-8.77 8.77 8.77 8.77 0 0 1-8.77-8.77 8.77 8.77 0 0 1 8.77-8.77z"/>
<circle style="opacity:1;fill:#666;stroke:none;stroke-width:.276252;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" cx="-20.704" cy="111.065" r="3.041"/>
<path style="fill:#666;stroke-width:.00362641;fill-opacity:1" d="M-17.936 117.039c-4.746 1.911-6.857-.321-8.826-3.398.058-.03.967-.532 1.018-.558 1.935 2.8 3.752 4.62 7.194 2.712l-.482-.983c.787.2 1.619.415 2.465.64-.322.801-.639 1.604-.966 2.402l-.403-.815zM-23.767 105.435c4.745-1.911 6.856.321 8.825 3.398-.058.03-.966.532-1.017.558-1.935-2.8-3.753-4.62-7.195-2.712l.482.983c-.787-.2-1.618-.415-2.465-.64.322-.801.64-1.604.967-2.402.134.272.267.543.403.815z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

7
p/themes/icons/opml.svg Normal file
View File

@ -0,0 +1,7 @@
<svg width="93.619" height="93.619" viewBox="0 0 24.77 24.77" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(33.09 -98.68)">
<path style="fill:#000;stroke-width:.264583" d="M98.217 120.656V96.711H132.084v47.89H98.217Zm19.925 5.03c5.583-1.455 9.438-6.373 9.438-12.041 0-7.464-6.765-13.365-14.031-12.24-9.125 1.412-13.645 11.61-8.549 19.289 2.769 4.17 8.27 6.26 13.142 4.992zm-5.108-3.822c-1.295-.395-2.793-1.303-3.807-2.306-3.247-3.214-3.255-8.545-.018-11.837 1.622-1.65 3.547-2.412 6.074-2.402 2.59.009 4.158.678 5.999 2.558 1.733 1.77 2.423 3.685 2.272 6.297-.2 3.452-2.21 6.207-5.398 7.4-1.464.547-3.839.682-5.122.29zm3.713-5.181c.92-.476 1.578-1.634 1.578-2.774 0-2.794-3.33-4.117-5.331-2.117-.626.625-.754.977-.754 2.061 0 2.57 2.263 3.99 4.507 2.83z"/>
<path style="opacity:1;fill:#666;stroke:none;stroke-width:1.12498;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" d="M-20.705 98.68a12.385 12.385 0 0 0-12.384 12.385 12.385 12.385 0 0 0 12.384 12.385 12.385 12.385 0 0 0 12.386-12.385A12.385 12.385 0 0 0-20.705 98.68zm0 3.616a8.77 8.77 0 0 1 8.77 8.77 8.77 8.77 0 0 1-8.77 8.77 8.77 8.77 0 0 1-8.77-8.77 8.77 8.77 0 0 1 8.77-8.77z"/>
<circle style="opacity:1;fill:#666;stroke:none;stroke-width:.276252;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" cx="-20.704" cy="111.065" r="3.041"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB