diff options
| author | 2022-02-28 20:22:43 +0100 | |
|---|---|---|
| committer | 2022-02-28 20:22:43 +0100 | |
| commit | 1fe66ad020ca8f0560bb9c6e311852ed77228f78 (patch) | |
| tree | df78da3f33a9f13a9d6ba3f2744c369bd6e313a6 | |
| parent | fa23ae76ea46b329fb65329081df95e864b03b23 (diff) | |
Implement Web scraping "HTML + XPath" (#4220)
* More PHP type hints for Fever
Follow-up of https://github.com/FreshRSS/FreshRSS/pull/4201
Related to https://github.com/FreshRSS/FreshRSS/issues/4200
* Detail
* Draft
* Progress
* More draft
* Fix thumbnail PHP type hint
https://github.com/FreshRSS/FreshRSS/issues/4215
* More types
* A bit more
* Refactor FreshRSS_Entry::fromArray
* Progress
* Starts to work
* Categories
* Fonctional
* Layout update
* Fix relative URLs
* Cache system
* Forgotten files
* Remove a debug line
* Automatic form validation of XPath expressions
* data-leave-validation
* Fix reload action
* Simpler examples
* Fix column type for PostgreSQL
* Enforce HTTP encoding
* Readme
* Fix get full content
* target="_blank"
* gitignore
* htmlspecialchars_utf8
* Implement HTML <base>
And fix/revert `xml:base` support in SimplePie https://github.com/simplepie/simplepie/commit/e49c578817aa504d8d05cd7f33857aeda9d41908
* SimplePie upstream PR merged
https://github.com/simplepie/simplepie/pull/723
56 files changed, 1567 insertions, 155 deletions
diff --git a/README.fr.md b/README.fr.md index b5cec608c..960d89f5d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -15,6 +15,8 @@ Il y a une API pour les clients (mobiles), ainsi qu’une [interface en ligne de Grâce au standard [WebSub](https://www.w3.org/TR/websub/) (anciennement [PubSubHubbub](https://github.com/pubsubhubbub/PubSubHubbub)), FreshRSS est capable de recevoir des notifications push instantanées depuis les sources compatibles, telles [Mastodon](https://joinmastodon.org), [Friendica](https://friendi.ca), [WordPress](https://wordpress.org/plugins/pubsubhubbub/), Blogger, FeedBurner, etc. +FreshRSS supporte nativement le moissonnage du Web (Web Scraping) basique, basé sur [XPath](https://www.w3.org/TR/xpath-10/), pour les sites Web sans flux RSS / Atom. + Enfin, il permet l’ajout d’[extensions](#extensions) pour encore plus de personnalisation. Les demandes de fonctionnalités, rapports de bugs, et autres contributions sont les bienvenues. Privilégiez pour cela des [demandes sur GitHub](https://github.com/FreshRSS/FreshRSS/issues). @@ -15,6 +15,8 @@ There is an API for (mobile) clients, and a [Command-Line Interface](cli/README. Thanks to the [WebSub](https://www.w3.org/TR/websub/) standard (formerly [PubSubHubbub](https://github.com/pubsubhubbub/PubSubHubbub)), FreshRSS is able to receive instant push notifications from compatible sources, such as [Mastodon](https://joinmastodon.org), [Friendica](https://friendi.ca), [WordPress](https://wordpress.org/plugins/pubsubhubbub/), Blogger, FeedBurner, etc. +FreshRSS natively supports basic Web scraping, based on [XPath](https://www.w3.org/TR/xpath-10/), for Web sites not providing any RSS / Atom feed. + Finally, it supports [extensions](#extensions) for further tuning. Feature requests, bug reports, and other contributions are welcome. The best way to contribute is to [open an issue on GitHub](https://github.com/FreshRSS/FreshRSS/issues). diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index f18a67072..dabfb348f 100755 --- a/app/Controllers/feedController.php +++ b/app/Controllers/feedController.php @@ -38,7 +38,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { * @throws FreshRSS_Feed_Exception * @throws Minz_FileNotExistException */ - public static function addFeed($url, $title = '', $cat_id = 0, $new_cat_name = '', $http_auth = '', $attributes = array()) { + public static function addFeed($url, $title = '', $cat_id = 0, $new_cat_name = '', $http_auth = '', $attributes = array(), $kind = FreshRSS_Feed::KIND_RSS) { FreshRSS_UserDAO::touch(); @set_time_limit(300); @@ -67,10 +67,19 @@ 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 + $feed->_kind($kind); $feed->_attributes('', $attributes); $feed->_httpAuth($http_auth); - $feed->load(true); //Throws FreshRSS_Feed_Exception, Minz_FileNotExistException $feed->_category($cat_id); + switch ($kind) { + case FreshRSS_Feed::KIND_RSS: + case FreshRSS_Feed::KIND_RSS_FORCED: + $feed->load(true); //Throws FreshRSS_Feed_Exception, Minz_FileNotExistException + break; + case FreshRSS_Feed::KIND_HTML_XPATH: + $feed->_website($url); + break; + } $feedDAO = FreshRSS_Factory::createFeedDao(); if ($feedDAO->searchByUrl($feed->url())) { @@ -85,8 +94,9 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $values = array( 'url' => $feed->url(), + 'kind' => $feed->kind(), 'category' => $feed->category(), - 'name' => $title != '' ? $title : $feed->name(), + 'name' => $title != '' ? $title : $feed->name(true), 'website' => $feed->website(), 'description' => $feed->description(), 'lastUpdate' => 0, @@ -184,8 +194,25 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $timeout = intval(Minz_Request::param('timeout', 0)); $attributes['timeout'] = $timeout > 0 ? $timeout : null; + $feed_kind = Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS); + if ($feed_kind == FreshRSS_Feed::KIND_HTML_XPATH) { + $xPathSettings = []; + if (Minz_Request::param('xPathFeedTitle', '') != '') $xPathSettings['feedTitle'] = Minz_Request::param('xPathFeedTitle', '', true); + if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true); + if (Minz_Request::param('xPathItemTitle', '') != '') $xPathSettings['itemTitle'] = Minz_Request::param('xPathItemTitle', '', true); + if (Minz_Request::param('xPathItemContent', '') != '') $xPathSettings['itemContent'] = Minz_Request::param('xPathItemContent', '', true); + if (Minz_Request::param('xPathItemUri', '') != '') $xPathSettings['itemUri'] = Minz_Request::param('xPathItemUri', '', true); + if (Minz_Request::param('xPathItemAuthor', '') != '') $xPathSettings['itemAuthor'] = Minz_Request::param('xPathItemAuthor', '', true); + if (Minz_Request::param('xPathItemTimestamp', '') != '') $xPathSettings['itemTimestamp'] = Minz_Request::param('xPathItemTimestamp', '', true); + if (Minz_Request::param('xPathItemThumbnail', '') != '') $xPathSettings['itemThumbnail'] = Minz_Request::param('xPathItemThumbnail', '', true); + if (Minz_Request::param('xPathItemCategories', '') != '') $xPathSettings['itemCategories'] = Minz_Request::param('xPathItemCategories', '', true); + if (!empty($xPathSettings)) { + $attributes['xpath'] = $xPathSettings; + } + } + try { - $feed = self::addFeed($url, '', $cat, '', $http_auth, $attributes); + $feed = self::addFeed($url, '', $cat, '', $http_auth, $attributes, $feed_kind); } catch (FreshRSS_BadUrl_Exception $e) { // Given url was not a valid url! Minz_Log::warning($e->getMessage()); @@ -264,6 +291,14 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } } + /** + * @param int $feed_id + * @param string $feed_url + * @param bool $force + * @param SimplePie|null $simplePiePush + * @param bool $noCommit + * @param int $maxFeeds + */ public static function actualizeFeed($feed_id, $feed_url, $force, $simplePiePush = null, $noCommit = false, $maxFeeds = 10) { @set_time_limit(300); @@ -338,6 +373,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { try { if ($simplePiePush) { $simplePie = $simplePiePush; //Used by WebSub + } elseif ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH) { + $simplePie = $feed->loadHtmlXpath(false, $isNewFeed); } else { $simplePie = $feed->load(false, $isNewFeed); } @@ -377,6 +414,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $oldGuids = array(); // Add entries in database if possible. + /** @var FreshRSS_Entry $entry */ foreach ($entries as $entry) { if (isset($newGuids[$entry->guid()])) { continue; //Skip subsequent articles with same GUID @@ -765,7 +803,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { //Re-fetch articles as if the feed was new. $feedDAO->updateFeed($feed->id(), [ 'lastUpdate' => 0 ]); - self::actualizeFeed($feed_id, null, false, null, true); + self::actualizeFeed($feed_id, '', false); //Extract all feed entries from database, load complete content and store them back in database. $entries = $entryDAO->listWhere('f', $feed_id, FreshRSS_Entry::STATE_ALL, 'DESC', 0); diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php index 0fc7bb61a..4f2f0d451 100755 --- a/app/Controllers/indexController.php +++ b/app/Controllers/indexController.php @@ -160,7 +160,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { } // No layout for RSS output. - $this->view->url = PUBLIC_TO_INDEX_PATH . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']); + $this->view->rss_url = PUBLIC_TO_INDEX_PATH . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']); $this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title(); $this->view->_layout(false); header('Content-Type: application/rss+xml; charset=utf-8'); diff --git a/app/Controllers/subscriptionController.php b/app/Controllers/subscriptionController.php index 7d2c58714..8fa468b8e 100644 --- a/app/Controllers/subscriptionController.php +++ b/app/Controllers/subscriptionController.php @@ -192,8 +192,26 @@ 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) { + $xPathSettings = []; + if (Minz_Request::param('xPathFeedTitle', '') != '') $xPathSettings['feedTitle'] = Minz_Request::param('xPathFeedTitle', '', true); + if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true); + if (Minz_Request::param('xPathItemTitle', '') != '') $xPathSettings['itemTitle'] = Minz_Request::param('xPathItemTitle', '', true); + if (Minz_Request::param('xPathItemContent', '') != '') $xPathSettings['itemContent'] = Minz_Request::param('xPathItemContent', '', true); + if (Minz_Request::param('xPathItemUri', '') != '') $xPathSettings['itemUri'] = Minz_Request::param('xPathItemUri', '', true); + if (Minz_Request::param('xPathItemAuthor', '') != '') $xPathSettings['itemAuthor'] = Minz_Request::param('xPathItemAuthor', '', true); + if (Minz_Request::param('xPathItemTimestamp', '') != '') $xPathSettings['itemTimestamp'] = Minz_Request::param('xPathItemTimestamp', '', true); + if (Minz_Request::param('xPathItemThumbnail', '') != '') $xPathSettings['itemThumbnail'] = Minz_Request::param('xPathItemThumbnail', '', true); + if (Minz_Request::param('xPathItemCategories', '') != '') $xPathSettings['itemCategories'] = Minz_Request::param('xPathItemCategories', '', true); + if (!empty($xPathSettings)) { + $feed->_attributes('xpath', $xPathSettings); + } + } + $values = array( 'name' => Minz_Request::param('name', ''), + 'kind' => $feed_kind, 'description' => sanitizeHTML(Minz_Request::param('description', '', true)), 'website' => checkUrl(Minz_Request::param('website', '')), 'url' => checkUrl(Minz_Request::param('url', '')), diff --git a/app/Models/Entry.php b/app/Models/Entry.php index a190e505d..ab88d777a 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -59,6 +59,38 @@ class FreshRSS_Entry extends Minz_Model { $this->_guid($guid); } + /** @param array<string,mixed> $dao */ + public static function fromArray(array $dao): FreshRSS_Entry { + if (!isset($dao['content'])) { + $dao['content'] = ''; + } + if (isset($dao['thumbnail'])) { + $dao['content'] .= '<p class="enclosure-content"><img src="' . $dao['thumbnail'] . '" alt="" /></p>'; + } + $entry = new FreshRSS_Entry( + $dao['id_feed'] ?? 0, + $dao['guid'] ?? '', + $dao['title'] ?? '', + $dao['author'] ?? '', + $dao['content'] ?? '', + $dao['link'] ?? '', + $dao['date'] ?? 0, + $dao['is_read'] ?? false, + $dao['is_favorite'] ?? false, + $dao['tags'] ?? '' + ); + if (isset($dao['id'])) { + $entry->_id($dao['id']); + } + if (!empty($dao['timestamp'])) { + $entry->_date(strtotime($dao['timestamp'])); + } + if (!empty($dao['categories'])) { + $entry->_tags($dao['categories']); + } + return $entry; + } + public function id(): string { return $this->id; } @@ -83,6 +115,7 @@ class FreshRSS_Entry extends Minz_Model { return $this->content; } + /** @return array<array<string,string>> */ public function enclosures(bool $searchBodyImages = false): array { $results = []; try { @@ -97,11 +130,20 @@ class FreshRSS_Entry extends Minz_Model { if ($searchEnclosures) { $enclosures = $xpath->query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]'); foreach ($enclosures as $enclosure) { - $results[] = [ + $result = [ 'url' => $enclosure->getAttribute('src'), 'type' => $enclosure->getAttribute('data-type'), + 'medium' => $enclosure->getAttribute('data-medium'), 'length' => $enclosure->getAttribute('data-length'), ]; + if (empty($result['medium'])) { + switch (strtolower($enclosure->nodeName)) { + case 'img': $result['medium'] = 'image'; break; + case 'video': $result['medium'] = 'video'; break; + case 'audio': $result['medium'] = 'audio'; break; + } + } + $results[] = $result; } } if ($searchBodyImages) { @@ -432,52 +474,12 @@ class FreshRSS_Entry extends Minz_Model { } } - public static function getContentByParsing(string $url, string $path, array $attributes = array(), int $maxRedirs = 3): string { - $limits = FreshRSS_Context::$system_conf->limits; - $feed_timeout = empty($attributes['timeout']) ? 0 : intval($attributes['timeout']); - - if (FreshRSS_Context::$system_conf->simplepie_syslog_enabled) { - syslog(LOG_INFO, 'FreshRSS GET ' . SimplePie_Misc::url_remove_credentials($url)); - } - - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_REFERER => SimplePie_Misc::url_remove_credentials($url), - CURLOPT_HTTPHEADER => array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'), - CURLOPT_USERAGENT => FRESHRSS_USERAGENT, - CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], - CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], - //CURLOPT_FAILONERROR => true; - CURLOPT_MAXREDIRS => 4, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_ENCODING => '', //Enable all encodings - ]); - - curl_setopt_array($ch, FreshRSS_Context::$system_conf->curl_options); - - if (isset($attributes['curl_params']) && is_array($attributes['curl_params'])) { - curl_setopt_array($ch, $attributes['curl_params']); - } - - if (isset($attributes['ssl_verify'])) { - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $attributes['ssl_verify'] ? 2 : 0); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $attributes['ssl_verify'] ? true : false); - if (!$attributes['ssl_verify']) { - curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); - } - } - $html = curl_exec($ch); - $c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $c_error = curl_error($ch); - curl_close($ch); - - if ($c_status != 200 || $c_error != '') { - Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url); - } - - if (is_string($html) && strlen($html) > 0) { + /** + * @param array<string,mixed> $attributes + */ + public static function getContentByParsing(string $url, string $path, array $attributes = [], int $maxRedirs = 3): string { + $html = getHtml($url, $attributes); + if (strlen($html) > 0) { require_once(LIB_PATH . '/lib_phpQuery.php'); /** * @var phpQueryObject @doc diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php index a10440edb..8f248e20f 100644 --- a/app/Models/EntryDAO.php +++ b/app/Models/EntryDAO.php @@ -164,7 +164,7 @@ INSERT IGNORE INTO `_entry` ( ) SELECT @rank:=@rank+1 AS id, guid, title, author, content_bin, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags FROM `_entrytmp` -ORDER BY date; +ORDER BY date, id; DELETE FROM `_entrytmp` WHERE id <= @rank; SQL; @@ -658,6 +658,7 @@ SQL; } } + /** @return FreshRSS_Entry|null */ public function searchByGuid($id_feed, $guid) { // un guid est unique pour un flux donné $sql = 'SELECT id, guid, title, author, ' @@ -669,9 +670,10 @@ SQL; $stm->bindParam(':guid', $guid); $stm->execute(); $res = $stm->fetchAll(PDO::FETCH_ASSOC); - return isset($res[0]) ? self::daoToEntry($res[0]) : null; + return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null; } + /** @return FreshRSS_Entry|null */ public function searchById($id) { $sql = 'SELECT id, guid, title, author, ' . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') @@ -681,7 +683,7 @@ SQL; $stm->bindParam(':id', $id, PDO::PARAM_INT); $stm->execute(); $res = $stm->fetchAll(PDO::FETCH_ASSOC); - return isset($res[0]) ? self::daoToEntry($res[0]) : null; + return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null; } public function searchIdByGuid($id_feed, $guid) { @@ -1061,7 +1063,7 @@ SQL; $stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min); if ($stm) { while ($row = $stm->fetch(PDO::FETCH_ASSOC)) { - yield self::daoToEntry($row); + yield FreshRSS_Entry::fromArray($row); } } else { yield false; @@ -1092,7 +1094,7 @@ SQL; $stm = $this->pdo->prepare($sql); $stm->execute($ids); while ($row = $stm->fetch(PDO::FETCH_ASSOC)) { - yield self::daoToEntry($row); + yield FreshRSS_Entry::fromArray($row); } } @@ -1251,23 +1253,4 @@ SQL; $unread = empty($res[1]) ? 0 : intval($res[1]); return array('all' => $all, 'unread' => $unread, 'read' => $all - $unread); } - - public static function daoToEntry($dao) { - $entry = new FreshRSS_Entry( - $dao['id_feed'], - $dao['guid'], - $dao['title'], - $dao['author'], - $dao['content'], - $dao['link'], - $dao['date'], - $dao['is_read'], - $dao['is_favorite'], - isset($dao['tags']) ? $dao['tags'] : '' - ); - if (isset($dao['id'])) { - $entry->_id($dao['id']); - } - return $entry; - } } diff --git a/app/Models/EntryDAOPGSQL.php b/app/Models/EntryDAOPGSQL.php index 7a46670fc..b97417a7c 100644 --- a/app/Models/EntryDAOPGSQL.php +++ b/app/Models/EntryDAOPGSQL.php @@ -45,13 +45,13 @@ rank bigint := (SELECT maxrank - COUNT(*) FROM `_entrytmp`); BEGIN INSERT INTO `_entry` (id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) - (SELECT rank + row_number() OVER(ORDER BY date) AS id, guid, title, author, content, + (SELECT rank + row_number() OVER(ORDER BY date, id) AS id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags FROM `_entrytmp` AS etmp WHERE NOT EXISTS ( SELECT 1 FROM `_entry` AS ereal WHERE (etmp.id = ereal.id) OR (etmp.id_feed = ereal.id_feed AND etmp.guid = ereal.guid)) - ORDER BY date); + ORDER BY date, id); DELETE FROM `_entrytmp` WHERE id <= maxrank; END $$;'; $hadTransaction = $this->pdo->inTransaction(); diff --git a/app/Models/EntryDAOSQLite.php b/app/Models/EntryDAOSQLite.php index 8b0f2d252..16d15f899 100644 --- a/app/Models/EntryDAOSQLite.php +++ b/app/Models/EntryDAOSQLite.php @@ -41,13 +41,13 @@ DROP TABLE IF EXISTS `tmp`; CREATE TEMP TABLE `tmp` AS SELECT id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags FROM `_entrytmp` - ORDER BY date; + ORDER BY date, id; INSERT OR IGNORE INTO `_entry` (id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) SELECT rowid + (SELECT MAX(id) - COUNT(*) FROM `tmp`) AS id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags FROM `tmp` - ORDER BY date; + ORDER BY date, id; DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`); DROP TABLE IF EXISTS `tmp`; '; diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 3425f4bce..0e02194ef 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -1,6 +1,28 @@ <?php class FreshRSS_Feed extends Minz_Model { + + /** + * Normal RSS or Atom feed + * @var int + */ + const KIND_RSS = 0; + /** + * Invalid RSS or Atom feed + * @var int + */ + const KIND_RSS_FORCED = 2; + /** + * Normal HTML with XPath scraping + * @var int + */ + const KIND_HTML_XPATH = 10; + /** + * Normal JSON with XPath scraping + * @var int + */ + const KIND_JSON_XPATH = 20; + const PRIORITY_MAIN_STREAM = 10; const PRIORITY_NORMAL = 0; const PRIORITY_ARCHIVED = -10; @@ -10,33 +32,50 @@ class FreshRSS_Feed extends Minz_Model { const ARCHIVING_RETENTION_COUNT_LIMIT = 10000; const ARCHIVING_RETENTION_PERIOD = 'P3M'; - /** - * @var int - */ + /** @var int */ private $id = 0; - private $url; - /** - * @var int - */ + /** @var string */ + private $url = ''; + /** @var int */ + private $kind = 0; + /** @var int */ private $category = 1; + /** @var int */ private $nbEntries = -1; + /** @var int */ private $nbNotRead = -1; + /** @var int */ private $nbPendingNotRead = 0; + /** @var string */ private $name = ''; + /** @var string */ private $website = ''; + /** @var string */ private $description = ''; + /** @var int */ private $lastUpdate = 0; + /** @var int */ private $priority = self::PRIORITY_MAIN_STREAM; + /** @var string */ private $pathEntries = ''; + /** @var string */ private $httpAuth = ''; + /** @var bool */ private $error = false; + /** @var int */ private $ttl = self::TTL_DEFAULT; private $attributes = []; + /** @var bool */ private $mute = false; + /** @var string */ private $hash = ''; + /** @var string */ private $lockPath = ''; + /** @var string */ private $hubUrl = ''; + /** @var string */ private $selfUrl = ''; + /** @var array<FreshRSS_FilterAction> $filterActions */ private $filterActions = null; public function __construct(string $url, bool $validate = true) { @@ -47,6 +86,9 @@ class FreshRSS_Feed extends Minz_Model { } } + /** + * @return FreshRSS_Feed + */ public static function example() { $f = new FreshRSS_Feed('http://example.net/', false); $f->faviconPrepare(); @@ -71,6 +113,9 @@ class FreshRSS_Feed extends Minz_Model { public function selfUrl(): string { return $this->selfUrl; } + public function kind(): int { + return $this->kind; + } public function hubUrl(): string { return $this->hubUrl; } @@ -200,6 +245,9 @@ class FreshRSS_Feed extends Minz_Model { } $this->url = $value; } + public function _kind($value) { + $this->kind = $value; + } public function _category($value) { $value = intval($value); $this->category = $value >= 0 ? $value : 0; @@ -267,7 +315,7 @@ class FreshRSS_Feed extends Minz_Model { * @return SimplePie|null */ public function load(bool $loadDetails = false, bool $noCache = false) { - if ($this->url !== null) { + if ($this->url != '') { // @phpstan-ignore-next-line if (CACHE_PATH === false) { throw new Minz_FileNotExistException( @@ -347,6 +395,7 @@ class FreshRSS_Feed extends Minz_Model { $guids = []; $hasBadGuids = $this->attributes('hasBadGuids'); + // TODO: Replace very slow $simplePie->get_item($i) by getting all items at once for ($i = $simplePie->get_item_quantity() - 1; $i >= 0; $i--) { $item = $simplePie->get_item($i); if ($item == null) { @@ -375,6 +424,7 @@ class FreshRSS_Feed extends Minz_Model { $hasBadGuids = $this->attributes('hasBadGuids'); // We want chronological order and SimplePie uses reverse order. + // TODO: Replace very slow $simplePie->get_item($i) by getting all items at once for ($i = $simplePie->get_item_quantity() - 1; $i >= 0; $i--) { $item = $simplePie->get_item($i); if ($item == null) { @@ -428,15 +478,18 @@ class FreshRSS_Feed extends Minz_Model { } elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) { $enclosureContent .= '<p class="enclosure-content"><audio preload="none" src="' . $elink . ($length == null ? '' : '" data-length="' . intval($length)) - . '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8') + . ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8')) . '" controls="controls"></audio> <a download="" href="' . $elink . '">💾</a></p>'; } elseif ($medium === 'video' || strpos($mime, 'video') === 0) { $enclosureContent .= '<p class="enclosure-content"><video preload="none" src="' . $elink . ($length == null ? '' : '" data-length="' . intval($length)) - . '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8') + . ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8')) . '" controls="controls"></video> <a download="" href="' . $elink . '">💾</a></p>'; } else { //e.g. application, text, unknown - $enclosureContent .= '<p class="enclosure-content"><a download="" href="' . $elink . '">💾</a></p>'; + $enclosureContent .= '<p class="enclosure-content"><a download="" href="' . $elink + . ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8')) + . ($medium == '' ? '' : '" data-medium="' . htmlspecialchars($medium, ENT_COMPAT, 'UTF-8')) + . '">💾</a></p>'; } $thumbnailContent = ''; @@ -490,6 +543,97 @@ class FreshRSS_Feed extends Minz_Model { } /** + * @param array<string,mixed> $attributes + * @return SimplePie|null + */ + public function loadHtmlXpath(bool $loadDetails = false, bool $noCache = false, array $attributes = []) { + if ($this->url == '') { + return null; + } + $feedSourceUrl = htmlspecialchars_decode($this->url, ENT_QUOTES); + if ($this->httpAuth != '') { + $feedSourceUrl = preg_replace('#((.+)://)(.+)#', '${1}' . $this->httpAuth . '@${3}', $feedSourceUrl); + } + + // Same naming conventions than https://github.com/RSS-Bridge/rss-bridge/wiki/XPathAbstract + // https://github.com/RSS-Bridge/rss-bridge/wiki/The-collectData-function + /** @var array<string,string> */ + $xPathSettings = $this->attributes('xpath'); + $xPathFeedTitle = $xPathSettings['feedTitle'] ?? ''; + $xPathItem = $xPathSettings['item'] ?? ''; + $xPathItemTitle = $xPathSettings['itemTitle'] ?? ''; + $xPathItemContent = $xPathSettings['itemContent'] ?? ''; + $xPathItemUri = $xPathSettings['itemUri'] ?? ''; + $xPathItemAuthor = $xPathSettings['itemAuthor'] ?? ''; + $xPathItemTimestamp = $xPathSettings['itemTimestamp'] ?? ''; + $xPathItemThumbnail = $xPathSettings['itemThumbnail'] ?? ''; + $xPathItemCategories = $xPathSettings['itemCategories'] ?? ''; + if ($xPathItem == '') { + return null; + } + + $html = getHtml($feedSourceUrl, $attributes); + if (strlen($html) <= 0) { + return null; + } + + $view = new FreshRSS_View(); + $view->_path('index/rss.phtml'); + $view->internal_rendering = true; + $view->rss_url = $feedSourceUrl; + $view->entries = []; + + try { + $doc = new DOMDocument(); + $doc->recover = true; + $doc->strictErrorChecking = false; + $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + $xpath = new DOMXPath($doc); + $view->rss_title = $xPathFeedTitle == '' ? '' : htmlspecialchars(@$xpath->evaluate('normalize-space(' . $xPathFeedTitle . ')'), ENT_COMPAT, 'UTF-8'); + $view->rss_base = htmlspecialchars(trim($xpath->evaluate('normalize-space(//base/@href)')), ENT_COMPAT, 'UTF-8'); + $nodes = $xpath->query($xPathItem); + if (empty($nodes)) { + return null; + } + + foreach ($nodes as $node) { + $item = []; + $item['title'] = $xPathItemTitle == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTitle . ')', $node); + $item['content'] = $xPathItemContent == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemContent . ')', $node); + $item['link'] = $xPathItemUri == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemUri . ')', $node); + $item['author'] = $xPathItemAuthor == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemAuthor . ')', $node); + $item['timestamp'] = $xPathItemTimestamp == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTimestamp . ')', $node); + $item['thumbnail'] = $xPathItemThumbnail == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemThumbnail . ')', $node); + if ($xPathItemCategories != '') { + $itemCategories = @$xpath->query($xPathItemCategories); + if ($itemCategories) { + foreach ($itemCategories as $itemCategory) { + $item['categories'][] = $itemCategory->textContent; + } + } + } + if ($item['title'] . $item['content'] . $item['link'] != '') { + $item['guid'] = 'urn:sha1:' . sha1($item['title'] . $item['content'] . $item['link']); + $item = Minz_Helper::htmlspecialchars_utf8($item); + $view->entries[] = FreshRSS_Entry::fromArray($item); + } + } + } catch (Exception $ex) { + Minz_Log::warning($ex->getMessage()); + return null; + } + + if (count($view->entries) < 1) { + return null; + } + + $simplePie = customSimplePie(); + $simplePie->set_raw_data($view->renderToString()); + $simplePie->init(); + return $simplePie; + } + + /** * To keep track of some new potentially unread articles since last commit+fetch from database */ public function incPendingUnread(int $n = 1) { @@ -532,18 +676,23 @@ class FreshRSS_Feed extends Minz_Model { return false; } - protected function cacheFilename(): string { - $simplePie = customSimplePie($this->attributes()); - $filename = $simplePie->get_cache_filename($this->url); - return CACHE_PATH . '/' . $filename . '.spc'; + public static function cacheFilename(string $url, array $attributes, int $kind = FreshRSS_Feed::KIND_RSS): string { + $simplePie = customSimplePie($attributes); + $filename = $simplePie->get_cache_filename($url); + if ($kind == FreshRSS_Feed::KIND_HTML_XPATH) { + return CACHE_PATH . '/' . $filename . '.html'; + } else { + return CACHE_PATH . '/' . $filename . '.spc'; + } } public function clearCache(): bool { - return @unlink($this->cacheFilename()); + return @unlink(FreshRSS_Feed::cacheFilename($this->url, $this->attributes(), $this->kind)); } + /** @return int|false */ public function cacheModifiedTime() { - return @filemtime($this->cacheFilename()); + return @filemtime(FreshRSS_Feed::cacheFilename($this->url, $this->attributes(), $this->kind)); } public function lock(): bool { @@ -567,7 +716,7 @@ class FreshRSS_Feed extends Minz_Model { * @return array<FreshRSS_FilterAction> */ public function filterActions(): array { - if ($this->filterActions == null) { + if (empty($this->filterActions)) { $this->filterActions = array(); $filters = $this->attributes('filters'); if (is_array($filters)) { @@ -582,6 +731,9 @@ class FreshRSS_Feed extends Minz_Model { return $this->filterActions; } + /** + * @param array<FreshRSS_FilterAction> $filterActions + */ private function _filterActions($filterActions) { $this->filterActions = $filterActions; if (is_array($this->filterActions) && !empty($this->filterActions)) { diff --git a/app/Models/FeedDAO.php b/app/Models/FeedDAO.php index ab73b2ec2..c4a0b1429 100644 --- a/app/Models/FeedDAO.php +++ b/app/Models/FeedDAO.php @@ -5,7 +5,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { protected function addColumn(string $name) { Minz_Log::warning(__method__ . ': ' . $name); try { - if ($name === 'attributes') { //v1.11.0 + if ($name === 'kind') { //v1.20.0 + return $this->pdo->exec('ALTER TABLE `_feed` ADD COLUMN kind SMALLINT DEFAULT 0') !== false; + } elseif ($name === 'attributes') { //v1.11.0 return $this->pdo->exec('ALTER TABLE `_feed` ADD COLUMN attributes TEXT') !== false; } } catch (Exception $e) { @@ -17,7 +19,7 @@ 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) { - foreach (['attributes'] as $column) { + foreach (['attributes', 'kind'] as $column) { if (stripos($errorInfo[2], $column) !== false) { return $this->addColumn($column); } @@ -32,6 +34,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { INSERT INTO `_feed` ( url, + kind, category, name, website, @@ -45,7 +48,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { attributes ) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; $stm = $this->pdo->prepare($sql); $valuesTmp['url'] = safe_ascii($valuesTmp['url']); @@ -59,6 +62,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { $values = array( substr($valuesTmp['url'], 0, 511), + $valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS, $valuesTmp['category'], mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'), substr($valuesTmp['website'], 0, 255), @@ -84,7 +88,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { } } - public function addFeedObject($feed): int { + 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 @@ -94,6 +98,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { $values = array( 'id' => $feed->id(), 'url' => $feed->url(), + 'kind' => $feed->kind(), 'category' => $feed->category(), 'name' => $feed->name(), 'website' => $feed->website(), @@ -252,7 +257,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { public function selectAll() { $sql = <<<'SQL' -SELECT id, url, category, name, website, description, `lastUpdate`, +SELECT id, url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes FROM `_feed` SQL; @@ -346,7 +351,7 @@ SQL; */ public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0) { $this->updateTTL(); - $sql = 'SELECT id, url, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes ' + $sql = 'SELECT id, url, kind, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes ' . 'FROM `_feed` ' . ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT . ' AND `lastUpdate` < (' . (time() + 60) @@ -557,20 +562,21 @@ SQL; $category = $catID; } - $myFeed = new FreshRSS_Feed(isset($dao['url']) ? $dao['url'] : '', false); + $myFeed = new FreshRSS_Feed($dao['url'] ?? '', false); + $myFeed->_kind($dao['kind'] ?? FreshRSS_Feed::KIND_RSS); $myFeed->_category($category); $myFeed->_name($dao['name']); - $myFeed->_website(isset($dao['website']) ? $dao['website'] : '', false); - $myFeed->_description(isset($dao['description']) ? $dao['description'] : ''); - $myFeed->_lastUpdate(isset($dao['lastUpdate']) ? $dao['lastUpdate'] : 0); - $myFeed->_priority(isset($dao['priority']) ? $dao['priority'] : 10); - $myFeed->_pathEntries(isset($dao['pathEntries']) ? $dao['pathEntries'] : ''); - $myFeed->_httpAuth(isset($dao['httpAuth']) ? base64_decode($dao['httpAuth']) : ''); - $myFeed->_error(isset($dao['error']) ? $dao['error'] : 0); - $myFeed->_ttl(isset($dao['ttl']) ? $dao['ttl'] : FreshRSS_Feed::TTL_DEFAULT); - $myFeed->_attributes('', isset($dao['attributes']) ? $dao['attributes'] : ''); - $myFeed->_nbNotRead(isset($dao['cache_nbUnreads']) ? $dao['cache_nbUnreads'] : 0); - $myFeed->_nbEntries(isset($dao['cache_nbEntries']) ? $dao['cache_nbEntries'] : 0); + $myFeed->_website($dao['website'] ?? '', false); + $myFeed->_description($dao['description'] ?? ''); + $myFeed->_lastUpdate($dao['lastUpdate'] ?? 0); + $myFeed->_priority($dao['priority'] ?? 10); + $myFeed->_pathEntries($dao['pathEntries'] ?? ''); + $myFeed->_httpAuth(base64_decode($dao['httpAuth'] ?? '')); + $myFeed->_error($dao['error'] ?? 0); + $myFeed->_ttl($dao['ttl'] ?? FreshRSS_Feed::TTL_DEFAULT); + $myFeed->_attributes('', $dao['attributes'] ?? ''); + $myFeed->_nbNotRead($dao['cache_nbUnreads'] ?? 0); + $myFeed->_nbEntries($dao['cache_nbEntries'] ?? 0); if (isset($dao['id'])) { $myFeed->_id($dao['id']); } diff --git a/app/Models/FeedDAOSQLite.php b/app/Models/FeedDAOSQLite.php index 54146858b..a4432ea62 100644 --- a/app/Models/FeedDAOSQLite.php +++ b/app/Models/FeedDAOSQLite.php @@ -5,7 +5,7 @@ class FreshRSS_FeedDAOSQLite extends FreshRSS_FeedDAO { protected function autoUpdateDb(array $errorInfo) { if ($tableInfo = $this->pdo->query("PRAGMA table_info('feed')")) { $columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1); - foreach (['attributes'] as $column) { + foreach (['attributes', 'kind'] as $column) { if (!in_array($column, $columns)) { return $this->addColumn($column); } diff --git a/app/Models/View.php b/app/Models/View.php index e3a591155..365bfd261 100644 --- a/app/Models/View.php +++ b/app/Models/View.php @@ -7,12 +7,19 @@ class FreshRSS_View extends Minz_View { public $callbackBeforeFeeds; public $callbackBeforePagination; public $categories; + /** @var FreshRSS_Category|null */ public $category; + /** @var string */ public $current_user; + /** @var array<FreshRSS_Entry> */ public $entries; + /** @var FreshRSS_Entry */ public $entry; + /** @var FreshRSS_Feed|null */ public $feed; + /** @var array<FreshRSS_Feed> */ public $feeds; + /** @var int */ public $nbUnreadTags; public $tags; @@ -88,8 +95,14 @@ class FreshRSS_View extends Minz_View { public $nbPage; // RSS view - public $rss_title; - public $url; + /** @var string */ + public $rss_title = ''; + /** @var string */ + public $rss_url = ''; + /** @var string */ + public $rss_base = ''; + /** @var boolean */ + public $internal_rendering = false; // Content preview public $fatalError; diff --git a/app/SQL/install.sql.mysql.php b/app/SQL/install.sql.mysql.php index 1fed64fda..c52b58f65 100644 --- a/app/SQL/install.sql.mysql.php +++ b/app/SQL/install.sql.mysql.php @@ -16,6 +16,7 @@ ENGINE = INNODB; CREATE TABLE IF NOT EXISTS `_feed` ( `id` SMALLINT NOT NULL AUTO_INCREMENT, -- v0.7 `url` VARCHAR(511) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, + `kind` SMALLINT DEFAULT 0, -- 0.20.0 `category` SMALLINT DEFAULT 0, -- v0.7 `name` VARCHAR(191) NOT NULL, `website` VARCHAR(255) CHARACTER SET latin1 COLLATE latin1_bin, diff --git a/app/SQL/install.sql.pgsql.php b/app/SQL/install.sql.pgsql.php index 5b810deff..0a8298d29 100644 --- a/app/SQL/install.sql.pgsql.php +++ b/app/SQL/install.sql.pgsql.php @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS `_category` ( CREATE TABLE IF NOT EXISTS `_feed` ( "id" SERIAL PRIMARY KEY, "url" VARCHAR(511) UNIQUE NOT NULL, + "kind" SMALLINT DEFAULT 0, -- 0.20.0 "category" SMALLINT DEFAULT 0, "name" VARCHAR(255) NOT NULL, "website" VARCHAR(255), diff --git a/app/SQL/install.sql.sqlite.php b/app/SQL/install.sql.sqlite.php index 74def4d98..44bf6fb33 100644 --- a/app/SQL/install.sql.sqlite.php +++ b/app/SQL/install.sql.sqlite.php @@ -14,6 +14,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, -- 0.20.0 `category` SMALLINT DEFAULT 0, `name` VARCHAR(255) NOT NULL, `website` VARCHAR(255), diff --git a/app/i18n/cz/sub.php b/app/i18n/cz/sub.php index 8b896586d..2eff49030 100644 --- a/app/i18n/cz/sub.php +++ b/app/i18n/cz/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informace', 'keep_min' => 'Minimální počet článků pro ponechání', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Vymazat mezipaměť', 'clear_cache_help' => 'Vymazat mezipaměť pro tento kanál.', diff --git a/app/i18n/de/sub.php b/app/i18n/de/sub.php index 2b52b59ee..52323221d 100644 --- a/app/i18n/de/sub.php +++ b/app/i18n/de/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Information', // IGNORE 'keep_min' => 'Minimale Anzahl an Artikeln, die behalten wird', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Zwischenspeicher leeren', 'clear_cache_help' => 'Zwischenspeicher für diesen Feed leeren.', diff --git a/app/i18n/en-us/sub.php b/app/i18n/en-us/sub.php index fc1c8358e..41b8c377b 100644 --- a/app/i18n/en-us/sub.php +++ b/app/i18n/en-us/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Information', // IGNORE 'keep_min' => 'Minimum number of articles to keep', // IGNORE + 'kind' => array( + '_' => 'Type of feed source', // IGNORE + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // IGNORE + 'feed_title' => array( + '_' => 'feed title', // IGNORE + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // IGNORE + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // IGNORE + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // IGNORE + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // IGNORE + ), + 'item_author' => array( + '_' => 'item author', // IGNORE + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // IGNORE + ), + 'item_categories' => 'items tags', // IGNORE + 'item_content' => array( + '_' => 'item content', // IGNORE + 'help' => 'Example to take the full item: <code>.</code>', // IGNORE + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // IGNORE + 'help' => 'Example: <code>descendant::img/@src</code>', // IGNORE + ), + 'item_timestamp' => array( + '_' => 'item date', // IGNORE + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // IGNORE + ), + 'item_title' => array( + '_' => 'item title', // IGNORE + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // IGNORE + ), + 'item_uri' => array( + '_' => 'item link (URL)', // IGNORE + 'help' => 'Example: <code>descendant::a/@href</code>', // IGNORE + ), + 'relative' => 'XPath (relative to item) for:', // IGNORE + 'xpath' => 'XPath for:', // IGNORE + ), + 'rss' => 'RSS / Atom (default)', // IGNORE + ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // IGNORE 'clear_cache_help' => 'Clear the cache for this feed.', // IGNORE diff --git a/app/i18n/en/sub.php b/app/i18n/en/sub.php index 2548916cf..902deb1b5 100644 --- a/app/i18n/en/sub.php +++ b/app/i18n/en/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Information', 'keep_min' => 'Minimum number of articles to keep', + 'kind' => array( + '_' => 'Type of feed source', + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', + 'feed_title' => array( + '_' => 'feed title', + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', + 'help' => 'Example: <code>//div[@class="news-item"]</code>', + ), + 'item_author' => array( + '_' => 'item author', + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', + ), + 'item_categories' => 'items tags', + 'item_content' => array( + '_' => 'item content', + 'help' => 'Example to take the full item: <code>.</code>', + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', + 'help' => 'Example: <code>descendant::img/@src</code>', + ), + 'item_timestamp' => array( + '_' => 'item date', + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', + ), + 'item_title' => array( + '_' => 'item title', + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', + ), + 'item_uri' => array( + '_' => 'item link (URL)', + 'help' => 'Example: <code>descendant::a/@href</code>', + ), + 'relative' => 'XPath (relative to item) for:', + 'xpath' => 'XPath for:', + ), + 'rss' => 'RSS / Atom (default)', + ), 'maintenance' => array( 'clear_cache' => 'Clear cache', 'clear_cache_help' => 'Clear the cache for this feed.', diff --git a/app/i18n/es/sub.php b/app/i18n/es/sub.php index ce29e369e..f55e0cbbb 100755 --- a/app/i18n/es/sub.php +++ b/app/i18n/es/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Información', 'keep_min' => 'Número mínimo de artículos a conservar', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Borrar caché', 'clear_cache_help' => 'Borrar la memoria caché de esta fuente.', diff --git a/app/i18n/fr/admin.php b/app/i18n/fr/admin.php index 706fa984d..4a628e2fe 100644 --- a/app/i18n/fr/admin.php +++ b/app/i18n/fr/admin.php @@ -72,8 +72,8 @@ return array( ), 'files' => 'Installation des fichiers', 'json' => array( - 'nok' => 'Vous ne disposez pas de l’extension recommendée JSON (paquet php-json).', - 'ok' => 'Vous disposez de l’extension recommendée JSON.', + 'nok' => 'Vous ne disposez pas de l’extension recommandée JSON (paquet php-json).', + 'ok' => 'Vous disposez de l’extension recommandée JSON.', ), 'mbstring' => array( 'nok' => 'Impossible de trouver la librairie recommandée mbstring pour Unicode.', @@ -199,7 +199,7 @@ return array( 'back_to_manage' => '← Revenir à la liste des utilisateurs', 'create' => 'Créer un nouvel utilisateur', 'database_size' => 'Volumétrie', - 'email' => 'Adresse email', + 'email' => 'adresse électronique', 'enabled' => 'Actif', 'feed_count' => 'Flux', 'is_admin' => 'Admin', diff --git a/app/i18n/fr/conf.php b/app/i18n/fr/conf.php index 4ebf7895b..8d097fa59 100644 --- a/app/i18n/fr/conf.php +++ b/app/i18n/fr/conf.php @@ -73,7 +73,7 @@ return array( '_' => 'Suppression du compte', 'warn' => 'Le compte et toutes les données associées vont être supprimées.', ), - 'email' => 'Adresse email', + 'email' => 'adresse électronique', 'password_api' => 'Mot de passe API<br /><small>(ex. : pour applis mobiles)</small>', 'password_form' => 'Mot de passe<br /><small>(pour connexion par formulaire)</small>', 'password_format' => '7 caractères minimum', @@ -185,7 +185,7 @@ return array( 'email' => 'Courriel', 'facebook' => 'Facebook', // IGNORE 'more_information' => 'Plus d’informations', - 'print' => 'Print', // IGNORE + 'print' => 'Imprimer', 'raindrop' => 'Raindrop.io', // IGNORE 'remove' => 'Supprimer la méthode de partage', 'shaarli' => 'Shaarli', // IGNORE diff --git a/app/i18n/fr/install.php b/app/i18n/fr/install.php index b9157ff53..d27fa6049 100644 --- a/app/i18n/fr/install.php +++ b/app/i18n/fr/install.php @@ -71,8 +71,8 @@ return array( 'ok' => 'Vous disposez de fileinfo.', ), 'json' => array( - 'nok' => 'Vous ne disposez pas de l’extension recommendée JSON (paquet php-json).', - 'ok' => 'Vous disposez de l’extension recommendée JSON.', + 'nok' => 'Vous ne disposez pas de l’extension recommandée JSON (paquet php-json).', + 'ok' => 'Vous disposez de l’extension recommandée JSON.', ), 'mbstring' => array( 'nok' => 'Impossible de trouver la librairie recommandée mbstring pour Unicode.', @@ -124,7 +124,7 @@ return array( 'missing_applied_migrations' => 'Quelque chose s’est mal passé, vous devriez créer le fichier <em>%s</em> à la main.', 'ok' => 'L’installation s’est bien passée.', 'session' => array( - 'nok' => 'Le serveur Web semble mal configué pour les cookies nécessaires aux sessions PHP!', + 'nok' => 'Le serveur Web semble mal configuré pour les cookies nécessaires aux sessions PHP!', ), 'step' => 'étape %d', 'steps' => 'Étapes', diff --git a/app/i18n/fr/sub.php b/app/i18n/fr/sub.php index 710d75918..c8528504a 100644 --- a/app/i18n/fr/sub.php +++ b/app/i18n/fr/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informations', 'keep_min' => 'Nombre minimum d’articles à conserver', + 'kind' => array( + '_' => 'Type de source de flux', + 'html_xpath' => array( + '_' => 'HTML + XPath (Moissonnage du Web)', + 'feed_title' => array( + '_' => 'titre de flux', + 'help' => 'Exemple : <code>//title</code> ou un text statique : <code>"Mon flux personnalisé"</code>', + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> est un langage de requête pour les utilisateurs avancés, supporté par FreshRSS pour le moissonnage du Web (Web scraping).', + 'item' => array( + '_' => 'trouver les <strong>articles</strong>', + 'help' => 'Exemple : <code>//div[@class="article"]</code>', + ), + 'item_author' => array( + '_' => 'auteur de l’article', + 'help' => 'Peut aussi être une chaîne de texte statique. Exemple : <code>"Anonyme"</code>', + ), + 'item_categories' => 'catégories (tags) de l’article', + 'item_content' => array( + '_' => 'contenu de l’article', + 'help' => 'Exemple pour prendre l’article complet : <code>.</code>', + ), + 'item_thumbnail' => array( + '_' => 'miniature de l’article', + 'help' => 'Exemple : <code>descendant::img/@src</code>', + ), + 'item_timestamp' => array( + '_' => 'date de l’article', + 'help' => 'Le résultat sera passé à la fonction <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', + ), + 'item_title' => array( + '_' => 'titre de l’article', + 'help' => 'Utiliser en particulier l’<a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">axe XPath</a> <code>descendant::</code> comme <code>descendant::h2</code>', + ), + 'item_uri' => array( + '_' => 'lien (URL) de l’article', + 'help' => 'Exemple : <code>descendant::a/@href</code>', + ), + 'relative' => 'XPath (relatif à l’article) pour :', + 'xpath' => 'XPath pour :', + ), + 'rss' => 'RSS / Atom (par défaut)', + ), 'maintenance' => array( 'clear_cache' => 'Vider le cache', 'clear_cache_help' => 'Supprime le cache de ce flux.', @@ -100,7 +143,7 @@ return array( 'ttl' => 'Ne pas automatiquement rafraîchir plus souvent que', 'url' => 'URL du flux', 'useragent' => 'Sélectionner l’agent utilisateur pour télécharger ce flux', - 'useragent_help' => 'Exemple: <kbd>Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0)</kbd>', + 'useragent_help' => 'Exemple : <kbd>Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0)</kbd>', 'validator' => 'Vérifier la validité du flux', 'website' => 'URL du site', 'websub' => 'Notification instantanée par WebSub', diff --git a/app/i18n/fr/user.php b/app/i18n/fr/user.php index e51c2910b..dabc5fab6 100644 --- a/app/i18n/fr/user.php +++ b/app/i18n/fr/user.php @@ -13,28 +13,28 @@ return array( 'email' => array( 'feedback' => array( - 'invalid' => 'L’adresse email est invalide.', - 'required' => 'L’adresse email est requise.', + 'invalid' => 'L’adresse électronique est invalide.', + 'required' => 'L’adresse électronique est requise.', ), 'validation' => array( - 'change_email' => 'Vous pouvez changer votre adresse email <a href="%s">dans votre profil</a>.', + 'change_email' => 'Vous pouvez changer votre adresse électronique <a href="%s">dans votre profil</a>.', 'email_sent_to' => 'Nous venons d’envoyer un email à <strong>%s</strong>, veuillez suivre ses indications pour valider votre adresse.', 'feedback' => array( 'email_failed' => 'Nous n’avons pas pu vous envoyer d’email à cause d’une mauvaise configuration du serveur.', 'email_sent' => 'Un email a été envoyé à votre adresse.', - 'error' => 'L’adresse email n’a pas pu être validée.', - 'ok' => 'L’adresse email a été validée.', - 'unnecessary' => 'L’adresse email a déjà été validée.', - 'wrong_token' => 'L’adresse email n’a pas pu être validée à cause d’un mauvais token.', + 'error' => 'L’adresse électronique n’a pas pu être validée.', + 'ok' => 'L’adresse électronique a été validée.', + 'unnecessary' => 'L’adresse électronique a déjà été validée.', + 'wrong_token' => 'L’adresse électronique n’a pas pu être validée à cause d’un mauvais token.', ), - 'need_to' => 'Vous devez valider votre adresse email avant de pouvoir utiliser %s.', + 'need_to' => 'Vous devez valider votre adresse électronique avant de pouvoir utiliser %s.', 'resend_email' => 'Renvoyer l’email', - 'title' => 'Validation de l’adresse email', + 'title' => 'Validation de l’adresse électronique', ), ), 'mailer' => array( 'email_need_validation' => array( - 'body' => 'Vous venez de vous inscrire sur %s mais vous devez encore valider votre adresse email. Pour cela, veuillez cliquer sur ce lien :', + 'body' => 'Vous venez de vous inscrire sur %s mais vous devez encore valider votre adresse électronique. Pour cela, veuillez cliquer sur ce lien :', 'title' => 'Vous devez valider votre compte', 'welcome' => 'Bienvenue %s,', ), diff --git a/app/i18n/he/sub.php b/app/i18n/he/sub.php index 1f4dc019f..6068a63c0 100644 --- a/app/i18n/he/sub.php +++ b/app/i18n/he/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'מידע', 'keep_min' => 'מסםר מינימלי של מאמרים לשמור', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // TODO 'clear_cache_help' => 'Clear the cache for this feed.', // TODO diff --git a/app/i18n/it/sub.php b/app/i18n/it/sub.php index ac5080ffb..cab35180e 100644 --- a/app/i18n/it/sub.php +++ b/app/i18n/it/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informazioni', 'keep_min' => 'Numero minimo di articoli da mantenere', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Clear cache', // TODO 'clear_cache_help' => 'Clear the cache for this feed.', // TODO diff --git a/app/i18n/ja/sub.php b/app/i18n/ja/sub.php index 4b68e46fd..ba7fa23b1 100644 --- a/app/i18n/ja/sub.php +++ b/app/i18n/ja/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'インフォメーション', 'keep_min' => '最小数の記事は保持されます', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'キャッシュのクリア', 'clear_cache_help' => 'このフィードのキャッシュをクリアします。', diff --git a/app/i18n/ko/sub.php b/app/i18n/ko/sub.php index 27b1f8bfa..ff9af8c39 100644 --- a/app/i18n/ko/sub.php +++ b/app/i18n/ko/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => '정보', 'keep_min' => '최소 유지 글 개수', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => '캐쉬 지우기', 'clear_cache_help' => '이 피드의 캐쉬 지우기.', diff --git a/app/i18n/nl/sub.php b/app/i18n/nl/sub.php index 611e97497..b8439f0b5 100644 --- a/app/i18n/nl/sub.php +++ b/app/i18n/nl/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informatie', 'keep_min' => 'Minimum aantal artikelen om te houden', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Cache leegmaken', 'clear_cache_help' => 'Cache voor deze feed leegmaken.', diff --git a/app/i18n/oc/sub.php b/app/i18n/oc/sub.php index fe4b38776..5cc7c792a 100644 --- a/app/i18n/oc/sub.php +++ b/app/i18n/oc/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informacions', 'keep_min' => 'Nombre minimum d’articles de servar', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Escafar lo cache', 'clear_cache_help' => 'Escafar lo cache d’aqueste flux sul disc', diff --git a/app/i18n/pl/sub.php b/app/i18n/pl/sub.php index 3c2f7b815..204d9ffef 100644 --- a/app/i18n/pl/sub.php +++ b/app/i18n/pl/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informacja', 'keep_min' => 'Minimalna liczba wiadomości do do przechowywania', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Wyczyść pamięć podręczną', 'clear_cache_help' => 'Czyści pamięć podręczną tego kanału.', diff --git a/app/i18n/pt-br/sub.php b/app/i18n/pt-br/sub.php index bc512e867..25d76ad9f 100644 --- a/app/i18n/pt-br/sub.php +++ b/app/i18n/pt-br/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informações', 'keep_min' => 'Número mínimo de artigos para manter', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Limpar o cache', 'clear_cache_help' => 'Limpar o cache em disco deste feed', diff --git a/app/i18n/ru/sub.php b/app/i18n/ru/sub.php index e11404674..1be761ab6 100644 --- a/app/i18n/ru/sub.php +++ b/app/i18n/ru/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Информация', 'keep_min' => 'Оставлять статей не менее', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Очистить кэш', 'clear_cache_help' => 'Очистить кэш для этой ленты.', diff --git a/app/i18n/sk/sub.php b/app/i18n/sk/sub.php index 3da71a24c..ef6e037fb 100644 --- a/app/i18n/sk/sub.php +++ b/app/i18n/sk/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Informácia', 'keep_min' => 'Minimálny počet článkov na uchovanie', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Vymazať vyrovnáciu pamäť', 'clear_cache_help' => 'Vymazať vyrovnáciu pamäť pre tento kanál.', diff --git a/app/i18n/tr/sub.php b/app/i18n/tr/sub.php index 4704b401c..e9f58f895 100644 --- a/app/i18n/tr/sub.php +++ b/app/i18n/tr/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => 'Bilgi', 'keep_min' => 'En az tutulacak makale sayısı', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => 'Önbelleği temizle', 'clear_cache_help' => 'Bu akışın önbelleğini temizler.', diff --git a/app/i18n/zh-cn/sub.php b/app/i18n/zh-cn/sub.php index 3fcdbf5c8..d45ba91eb 100644 --- a/app/i18n/zh-cn/sub.php +++ b/app/i18n/zh-cn/sub.php @@ -61,6 +61,49 @@ return array( ), 'information' => '信息', 'keep_min' => '至少保存的文章数', + 'kind' => array( + '_' => 'Type of feed source', // TODO + 'html_xpath' => array( + '_' => 'HTML + XPath (Web scraping)', // TODO + 'feed_title' => array( + '_' => 'feed title', // TODO + 'help' => 'Example: <code>//title</code> or a static string: <code>"My custom feed"</code>', // TODO + ), + 'help' => '<dfn><a href="https://www.w3.org/TR/xpath-10/" target="_blank">XPath 1.0</a></dfn> is a standard query language for advanced users, and which FreshRSS supports to enable Web scraping.', // TODO + 'item' => array( + '_' => 'finding news <strong>items</strong><br /><small>(most important)</small>', // TODO + 'help' => 'Example: <code>//div[@class="news-item"]</code>', // TODO + ), + 'item_author' => array( + '_' => 'item author', // TODO + 'help' => 'Can also be a static string. Example: <code>"Anonymous"</code>', // TODO + ), + 'item_categories' => 'items tags', // TODO + 'item_content' => array( + '_' => 'item content', // TODO + 'help' => 'Example to take the full item: <code>.</code>', // TODO + ), + 'item_thumbnail' => array( + '_' => 'item thumbnail', // TODO + 'help' => 'Example: <code>descendant::img/@src</code>', // TODO + ), + 'item_timestamp' => array( + '_' => 'item date', // TODO + 'help' => 'The result will be parsed by <a href="https://php.net/strtotime" target="_blank"><code>strtotime()</code></a>', // TODO + ), + 'item_title' => array( + '_' => 'item title', // TODO + 'help' => 'Use in particular the <a href="https://developer.mozilla.org/docs/Web/XPath/Axes" target="_blank">XPath axis</a> <code>descendant::</code> like <code>descendant::h2</code>', // TODO + ), + 'item_uri' => array( + '_' => 'item link (URL)', // TODO + 'help' => 'Example: <code>descendant::a/@href</code>', // TODO + ), + 'relative' => 'XPath (relative to item) for:', // TODO + 'xpath' => 'XPath for:', // TODO + ), + 'rss' => 'RSS / Atom (default)', // TODO + ), 'maintenance' => array( 'clear_cache' => '清理缓存', 'clear_cache_help' => '清除该feed的缓存', diff --git a/app/layout/layout.phtml b/app/layout/layout.phtml index cb9b6c1ba..1d41cc690 100644 --- a/app/layout/layout.phtml +++ b/app/layout/layout.phtml @@ -31,7 +31,7 @@ if (_t('gen.dir') === 'rtl') { <?= FreshRSS_View::headTitle() ?> <?php $url_base = Minz_Request::currentRequest(); - if (isset($this->rss_title)) { + if ($this->rss_title != '') { $url_rss = $url_base; $url_rss['a'] = 'rss'; if (FreshRSS_Context::$user_conf->since_hours_posts_per_rss) { diff --git a/app/views/helpers/export/articles.phtml b/app/views/helpers/export/articles.phtml index c131b8474..ad5210968 100644 --- a/app/views/helpers/export/articles.phtml +++ b/app/views/helpers/export/articles.phtml @@ -22,7 +22,7 @@ foreach ($this->entriesRaw as $entryRaw) { if ($entryRaw == null) { continue; } - $entry = FreshRSS_EntryDAO::daoToEntry($entryRaw); + $entry = FreshRSS_Entry::fromArray($entryRaw); if (!isset($this->feed)) { $feed = FreshRSS_CategoryDAO::findFeed($this->categories, $entry->feed()); if ($feed === null) { diff --git a/app/views/helpers/feed/update.phtml b/app/views/helpers/feed/update.phtml index 264881f77..f71be5135 100644 --- a/app/views/helpers/feed/update.phtml +++ b/app/views/helpers/feed/update.phtml @@ -373,6 +373,110 @@ </div> </div> + <legend><?= _t('sub.feed.kind') ?></legend> + <div class="form-group"> + <label class="group-name" for="feed_kind"><?= _t('sub.feed.kind') ?></label> + <div class="group-controls"> + <select name="feed_kind" id="feed_kind" class="select-show"> + <option value="<?= FreshRSS_Feed::KIND_RSS ?>" <?= $this->feed->kind() == FreshRSS_Feed::KIND_RSS ? 'selected="selected"' : '' ?>><?= _t('sub.feed.kind.rss') ?></option> + <option value="<?= FreshRSS_Feed::KIND_HTML_XPATH ?>" <?= $this->feed->kind() == FreshRSS_Feed::KIND_HTML_XPATH ? 'selected="selected"' : '' ?> data-show="html_xpath"><?= _t('sub.feed.kind.html_xpath') ?></option> + </select> + </div> + </div> + + <fieldset id="html_xpath"> + <?php + $xpath = Minz_Helper::htmlspecialchars_utf8($this->feed->attributes('xpath')); + ?> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.help') ?></p> + <div class="form-group"> + <label class="group-name" for="xPathFeedTitle"><small><?= _t('sub.feed.kind.html_xpath.xpath') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.feed_title') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathFeedTitle" id="xPathFeedTitle" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['feedTitle'] ?? '' ?>"><?= $xpath['feedTitle'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.feed_title.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItem"><small><?= _t('sub.feed.kind.html_xpath.xpath') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItem" id="xPathItem" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['item'] ?? '' ?>"><?= $xpath['item'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemTitle"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_title') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemTitle" id="xPathItemTitle" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemTitle'] ?? '' ?>"><?= $xpath['itemTitle'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_title.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemContent"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_content') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemContent" id="xPathItemContent" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemContent'] ?? '' ?>"><?= $xpath['itemContent'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_content.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemUri"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_uri') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemUri" id="xPathItemUri" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemUri'] ?? '' ?>"><?= $xpath['itemUri'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_uri.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemThumbnail"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_thumbnail') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemThumbnail" id="xPathItemThumbnail" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemThumbnail'] ?? '' ?>"><?= $xpath['itemThumbnail'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_thumbnail.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemAuthor"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_author') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemAuthor" id="xPathItemAuthor" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemAuthor'] ?? '' ?>"><?= $xpath['itemAuthor'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_author.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemTimestamp"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_timestamp') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemTimestamp" id="xPathItemTimestamp" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemTimestamp'] ?? '' ?>"><?= $xpath['itemTimestamp'] ?? '' ?></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_timestamp.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemCategories"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_categories') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemCategories" id="xPathItemCategories" rows="2" cols="64" spellcheck="false" + data-leave-validation="<?= $xpath['itemCategories'] ?? '' ?>"><?= $xpath['itemCategories'] ?? '' ?></textarea> + </div> + </div> + </fieldset> + <div class="form-group form-actions"> + <div class="group-controls"> + <button class="btn btn-important"><?= _t('gen.action.submit') ?></button> + <button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button> + </div> + </div> + <legend><?= _t('sub.feed.advanced') ?></legend> <div class="form-group"> <label class="group-name" for="path_entries"><?= _t('sub.feed.css_path') ?></label> diff --git a/app/views/index/normal.phtml b/app/views/index/normal.phtml index 5dde2a171..06323dcb0 100644 --- a/app/views/index/normal.phtml +++ b/app/views/index/normal.phtml @@ -21,14 +21,17 @@ $today = @strtotime('today'); </div><?php $lastEntry = null; $nbEntries = 0; + /** @var FreshRSS_Entry */ foreach ($this->entries as $item): $lastEntry = $item; $nbEntries++; ob_flush(); - $this->entry = Minz_ExtensionManager::callHook('entry_before_display', $item); - if ($this->entry == null) { + /** @var FreshRSS_Entry */ + $item = Minz_ExtensionManager::callHook('entry_before_display', $item); + if ($item == null) { continue; } + $this->entry = $item; // We most likely already have the feed object in cache $this->feed = FreshRSS_CategoryDAO::findFeed($this->categories, $this->entry->feed()); diff --git a/app/views/index/reader.phtml b/app/views/index/reader.phtml index e4fb74708..b408e3480 100644 --- a/app/views/index/reader.phtml +++ b/app/views/index/reader.phtml @@ -15,10 +15,12 @@ $content_width = FreshRSS_Context::$user_conf->content_width; </div><?php $lastEntry = null; $nbEntries = 0; + /** @var FreshRSS_Entry */ foreach ($this->entries as $item): $lastEntry = $item; $nbEntries++; ob_flush(); + /** @var FreshRSS_Entry */ $item = Minz_ExtensionManager::callHook('entry_before_display', $item); if ($item == null) { continue; diff --git a/app/views/index/rss.phtml b/app/views/index/rss.phtml index eedb31fa4..0b07a02f3 100755 --- a/app/views/index/rss.phtml +++ b/app/views/index/rss.phtml @@ -1,15 +1,26 @@ <?php /** @var FreshRSS_View $this */ ?> <?= '<?xml version="1.0" encoding="UTF-8" ?>'; ?> -<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"> +<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/" + <?= $this->rss_base == '' ? '' : ' xml:base="' . $this->rss_base . '"' ?> +> <channel> <title><?= $this->rss_title ?></title> - <link><?= Minz_Url::display('', 'html', true) ?></link> + <link><?= $this->internal_rendering ? $this->rss_url : Minz_Url::display('', 'html', true) ?></link> <description><?= _t('index.feed.rss_of', $this->rss_title) ?></description> <pubDate><?= date('D, d M Y H:i:s O') ?></pubDate> <lastBuildDate><?= gmdate('D, d M Y H:i:s') ?> GMT</lastBuildDate> - <atom:link href="<?= Minz_Url::display($this->url, 'html', true) ?>" rel="self" type="application/rss+xml" /> + <atom:link href="<?= $this->internal_rendering ? $this->rss_url : + Minz_Url::display($this->rss_url, 'html', true) ?>" rel="self" type="application/rss+xml" /> <?php +/** @var FreshRSS_Entry */ foreach ($this->entries as $item) { + if (!$this->internal_rendering) { + /** @var FreshRSS_Entry */ + $item = Minz_ExtensionManager::callHook('entry_before_display', $item); + if ($item == null) { + continue; + } + } ?> <item> <title><?= $item->title() ?></title> @@ -27,12 +38,23 @@ foreach ($this->entries as $item) { echo "\t\t\t" , '<category>', $category, '</category>', "\n"; } } + $enclosures = $item->enclosures(false); + if (is_array($enclosures)) { + foreach ($enclosures as $enclosure) { + // https://www.rssboard.org/media-rss + echo "\t\t\t" , '<media:content url="' . $enclosure['url'] + . (empty($enclosure['medium']) ? '' : '" medium="' . $enclosure['medium']) + . (empty($enclosure['type']) ? '' : '" type="' . $enclosure['type']) + . (empty($enclosure['length']) ? '' : '" length="' . $enclosure['length']) + . '"></media:content>', "\n"; + } + } ?> <description><![CDATA[<?php echo $item->content(); ?>]]></description> <pubDate><?= date('D, d M Y H:i:s O', $item->date(true)) ?></pubDate> - <guid isPermaLink="false"><?= $item->id() ?></guid> + <guid isPermaLink="false"><?= $item->id() > 0 ? $item->id() : $item->guid() ?></guid> </item> <?php } ?> diff --git a/app/views/subscription/add.phtml b/app/views/subscription/add.phtml index 380f5434f..344e25ade 100644 --- a/app/views/subscription/add.phtml +++ b/app/views/subscription/add.phtml @@ -53,6 +53,97 @@ <details class="form-advanced"> <summary class="form-advanced-title"> + <?= _t('sub.feed.kind') ?> + </summary> + + <div class="form-group"> + <label class="group-name" for="feed_kind"><?= _t('sub.feed.kind') ?></label> + <div class="group-controls"> + <select name="feed_kind" id="feed_kind" class="select-show"> + <option value="<?= FreshRSS_Feed::KIND_RSS ?>" selected="selected"><?= _t('sub.feed.kind.rss') ?></option> + <option value="<?= FreshRSS_Feed::KIND_HTML_XPATH ?>" data-show="html_xpath"><?= _t('sub.feed.kind.html_xpath') ?></option> + </select> + </div> + </div> + + <fieldset id="html_xpath"> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.help') ?></p> + <div class="form-group"> + <label class="group-name" for="xPathFeedTitle"><small><?= _t('sub.feed.kind.html_xpath.xpath') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.feed_title') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathFeedTitle" id="xPathFeedTitle" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.feed_title.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItem"><small><?= _t('sub.feed.kind.html_xpath.xpath') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItem" id="xPathItem" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemTitle"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_title') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemTitle" id="xPathItemTitle" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_title.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemContent"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_content') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemContent" id="xPathItemContent" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_content.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemUri"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_uri') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemUri" id="xPathItemUri" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_uri.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemThumbnail"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_thumbnail') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemThumbnail" id="xPathItemThumbnail" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_thumbnail.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemAuthor"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_author') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemAuthor" id="xPathItemAuthor" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_author.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemTimestamp"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_timestamp') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemTimestamp" id="xPathItemTimestamp" rows="2" cols="64" spellcheck="false"></textarea> + <p class="help"><?= _i('help') ?> <?= _t('sub.feed.kind.html_xpath.item_timestamp.help') ?></p> + </div> + </div> + <div class="form-group"> + <label class="group-name" for="xPathItemCategories"><small><?= _t('sub.feed.kind.html_xpath.relative') ?></small><br /> + <?= _t('sub.feed.kind.html_xpath.item_categories') ?></label> + <div class="group-controls"> + <textarea class="valid-xpath" name="xPathItemCategories" id="xPathItemCategories" rows="2" cols="64" spellcheck="false"></textarea> + </div> + </div> + </fieldset> + </details> + + <details class="form-advanced"> + <summary class="form-advanced-title"> <?= _t('sub.feed.advanced') ?> </summary> diff --git a/data/cache/.gitignore b/data/cache/.gitignore index 0307e6493..6c43765c7 100644 --- a/data/cache/.gitignore +++ b/data/cache/.gitignore @@ -1 +1,3 @@ -*.spc
\ No newline at end of file +*.spc +*.html +!index.html diff --git a/lib/Minz/Url.php b/lib/Minz/Url.php index be3184b40..40cadb49a 100644 --- a/lib/Minz/Url.php +++ b/lib/Minz/Url.php @@ -121,7 +121,8 @@ class Minz_Url { /** * @param string $controller * @param string $action - * @param string ...$args + * @param string|int ...$args + * @return string|false */ function _url ($controller, $action, ...$args) { $nb_args = count($args); @@ -132,8 +133,8 @@ function _url ($controller, $action, ...$args) { $params = array (); for ($i = 0; $i < $nb_args; $i += 2) { - $arg = $args[$i]; - $params[$arg] = $args[$i + 1]; + $arg = '' . $args[$i]; + $params[$arg] = '' . $args[$i + 1]; } return Minz_Url::display (array ('c' => $controller, 'a' => $action, 'params' => $params)); diff --git a/lib/Minz/View.php b/lib/Minz/View.php index 431a8b700..6cf811bff 100644 --- a/lib/Minz/View.php +++ b/lib/Minz/View.php @@ -112,6 +112,12 @@ class Minz_View { } } + public function renderToString(): string { + ob_start(); + $this->render(); + return ob_get_clean(); + } + /** * Ajoute un élément du layout * @param string $part l'élément partial à ajouter diff --git a/lib/SimplePie/SimplePie.php b/lib/SimplePie/SimplePie.php index b0e973e83..bf4a66bb4 100644 --- a/lib/SimplePie/SimplePie.php +++ b/lib/SimplePie/SimplePie.php @@ -2275,7 +2275,7 @@ class SimplePie */ public function get_base($element = array()) { - if (!($this->get_type() & SIMPLEPIE_TYPE_RSS_SYNDICATION) && !empty($element['xml_base_explicit']) && isset($element['xml_base'])) + if (!empty($element['xml_base_explicit']) && isset($element['xml_base'])) { return $element['xml_base']; } diff --git a/lib/lib_phpQuery.php b/lib/lib_phpQuery.php index 411aa120c..1fabfcb6d 100644 --- a/lib/lib_phpQuery.php +++ b/lib/lib_phpQuery.php @@ -436,7 +436,8 @@ class DOMDocumentWrapper { } protected function isXML($markup) { // return strpos($markup, '<?xml') !== false && stripos($markup, 'xhtml') === false; - return strpos(substr($markup, 0, 100), '<'.'?xml') !== false; + $head = substr($markup, 0, 100); + return strpos($head, '<'.'?xml') !== false && stripos($head, '<html ') === false; } protected function contentTypeToArray($contentType) { $matches = explode(';', trim(strtolower($contentType))); diff --git a/lib/lib_rss.php b/lib/lib_rss.php index e020236ea..4e415d857 100644 --- a/lib/lib_rss.php +++ b/lib/lib_rss.php @@ -218,6 +218,7 @@ function customSimplePie($attributes = array()): SimplePie { $simplePie->set_cache_name_function('sha1'); $simplePie->set_cache_location(CACHE_PATH); $simplePie->set_cache_duration($limits['cache_duration']); + $simplePie->enable_order_by_date(false); $feed_timeout = empty($attributes['timeout']) ? 0 : intval($attributes['timeout']); $simplePie->set_timeout($feed_timeout > 0 ? $feed_timeout : $limits['timeout']); @@ -290,7 +291,10 @@ function customSimplePie($attributes = array()): SimplePie { return $simplePie; } -function sanitizeHTML($data, $base = '', $maxLength = false) { +/** + * @param int|false $maxLength + */ +function sanitizeHTML($data, string $base = '', $maxLength = false) { if (!is_string($data) || ($maxLength !== false && $maxLength <= 0)) { return ''; } @@ -311,6 +315,127 @@ function sanitizeHTML($data, $base = '', $maxLength = false) { return $result; } +function cleanCache(int $hours = 720) { + $files = glob(CACHE_PATH . '/*.{html,spc}', GLOB_BRACE | GLOB_NOSORT); + foreach ($files as $file) { + if (substr($file, -10) === 'index.html') { + continue; + } + $cacheMtime = @filemtime($file); + if ($cacheMtime !== false && $cacheMtime < time() - (3600 * $hours)) { + unlink($file); + } + } +} + +/** + * Set an XML preamble to enforce the HTML content type charset received by HTTP. + * @param string $html the row downloaded HTML content + * @param string $contentType an HTTP Content-Type such as 'text/html; charset=utf-8' + * @return string an HTML string with XML encoding information for DOMDocument::loadHTML() + */ +function enforceHttpEncoding(string $html, string $contentType = ''): string { + $httpCharset = preg_match('/\bcharset=([0-9a-z_-]{2,12})$/i', $contentType, $matches) === false ? '' : $matches[1] ?? ''; + if ($httpCharset == '') { + // No charset defined by HTTP, do nothing + return $html; + } + $httpCharsetNormalized = SimplePie_Misc::encoding($httpCharset); + if ($httpCharsetNormalized === 'windows-1252') { + // Default charset for HTTP, do nothing + return $html; + } + if (substr($html, 0, 3) === "\xEF\xBB\xBF" || // UTF-8 BOM + substr($html, 0, 2) === "\xFF\xFE" || // UTF-16 Little Endian BOM + substr($html, 0, 2) === "\xFE\xFF" || // UTF-16 Big Endian BOM + substr($html, 0, 4) === "\xFF\xFE\x00\x00" || // UTF-32 Little Endian BOM + substr($html, 0, 4) === "\x00\x00\xFE\xFF") { // UTF-32 Big Endian BOM + // Existing byte order mark, do nothing + return $html; + } + if (preg_match('/^<[?]xml[^>]+encoding\b/', substr($html, 0, 64))) { + // Existing XML declaration, do nothing + return $html; + } + return '<' . '?xml version="1.0" encoding="' . $httpCharsetNormalized . '" ?' . ">\n" . $html; +} + +/** + * @param array<string,mixed> $attributes + */ +function getHtml(string $url, 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 != '') { + syslog(LOG_DEBUG, 'FreshRSS uses cache for ' . SimplePie_Misc::url_remove_credentials($url)); + return $html; + } + } + + if (mt_rand(0, 30) === 1) { // Remove old entries once in a while + cleanCache(); + } + + if (FreshRSS_Context::$system_conf->simplepie_syslog_enabled) { + syslog(LOG_INFO, 'FreshRSS GET ' . SimplePie_Misc::url_remove_credentials($url)); + } + + // TODO: Implement HTTP 1.1 conditional GET If-Modified-Since + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_REFERER => SimplePie_Misc::url_remove_credentials($url), + CURLOPT_HTTPHEADER => array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'), + CURLOPT_USERAGENT => FRESHRSS_USERAGENT, + CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], + CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], + //CURLOPT_FAILONERROR => true; + CURLOPT_MAXREDIRS => 4, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_ENCODING => '', //Enable all encodings + ]); + + curl_setopt_array($ch, FreshRSS_Context::$system_conf->curl_options); + + if (isset($attributes['curl_params']) && is_array($attributes['curl_params'])) { + curl_setopt_array($ch, $attributes['curl_params']); + } + + if (isset($attributes['ssl_verify'])) { + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $attributes['ssl_verify'] ? 2 : 0); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $attributes['ssl_verify'] ? true : false); + if (!$attributes['ssl_verify']) { + curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); + } + } + $html = 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) { + Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url); + } + if ($html == false) { + $html = ''; + } else { + $html = enforceHttpEncoding($html, $c_content_type); + } + + if (file_put_contents($cachePath, $html) === false) { + Minz_Log::warning("Error saving cache $cachePath for $url"); + } + + return $html; +} + /** * Validate an email address, supports internationalized addresses. * diff --git a/p/api/fever.php b/p/api/fever.php index beb0883e4..139cd658a 100644 --- a/p/api/fever.php +++ b/p/api/fever.php @@ -114,7 +114,7 @@ class FeverDAO extends Minz_ModelPdo $entries = array(); foreach ($result as $dao) { - $entries[] = FreshRSS_EntryDAO::daoToEntry($dao); + $entries[] = FreshRSS_Entry::fromArray($dao); } return $entries; diff --git a/p/api/greader.php b/p/api/greader.php index 7c4aba9ea..43e3647d1 100644 --- a/p/api/greader.php +++ b/p/api/greader.php @@ -536,6 +536,7 @@ function entriesToArray($entries) { $items = array(); foreach ($entries as $item) { + /** @var FreshRSS_Entry $entry */ $entry = Minz_ExtensionManager::callHook('entry_before_display', $item); if ($entry == null) { continue; diff --git a/p/scripts/extra.js b/p/scripts/extra.js index 00a460917..505b05110 100644 --- a/p/scripts/extra.js +++ b/p/scripts/extra.js @@ -213,6 +213,49 @@ function init_configuration_alert() { }; } +/** + * Allow a <select class="select-show"> to hide/show elements defined by <option data-show="elem-id"></option> + */ +function init_select_show() { + const listener = (select) => { + const options = select.querySelectorAll('option[data-show]'); + for (const option of options) { + const elem = document.getElementById(option.dataset.show); + if (elem) { + elem.style.display = option.selected ? 'block' : 'none'; + } + } + }; + + const selects = document.querySelectorAll('select.select-show'); + for (const select of selects) { + select.addEventListener('change', (e) => listener(e.target)); + listener(select); + } +} + +/** + * Automatically validate XPath textarea fields + */ +function init_valid_xpath() { + const listener = (textarea) => { + const evaluator = new XPathEvaluator(); + try { + if (textarea.value === '' || evaluator.createExpression(textarea.value) != null) { + textarea.setCustomValidity(''); + } + } catch (ex) { + textarea.setCustomValidity(ex); + } + }; + + const textareas = document.querySelectorAll('textarea.valid-xpath'); + for (const textarea of textareas) { + textarea.addEventListener('change', (e) => listener(e.target)); + listener(textarea); + } +} + function init_extra() { if (!window.context) { if (window.console) { @@ -227,6 +270,8 @@ function init_extra() { init_slider_observers(); init_configuration_alert(); fix_popup_preview_selector(); + init_select_show(); + init_valid_xpath(); } if (document.readyState && document.readyState !== 'loading') { diff --git a/p/themes/base-theme/template.css b/p/themes/base-theme/template.css index f0c4c0dfc..b6111788e 100644 --- a/p/themes/base-theme/template.css +++ b/p/themes/base-theme/template.css @@ -160,6 +160,14 @@ input, select, textarea { font-size: 0.8rem; } +textarea[rows="2"] { + height: 3em; +} + +textarea:invalid { + border: 2px dashed red; +} + input[type="radio"], input[type="checkbox"] { width: 15px !important; diff --git a/p/themes/base-theme/template.rtl.css b/p/themes/base-theme/template.rtl.css index 9bbe6fa9f..eeb4c69f5 100644 --- a/p/themes/base-theme/template.rtl.css +++ b/p/themes/base-theme/template.rtl.css @@ -160,6 +160,14 @@ input, select, textarea { font-size: 0.8rem; } +textarea[rows="2"] { + height: 3em; +} + +textarea:invalid { + border: 2px dashed red; +} + input[type="radio"], input[type="checkbox"] { width: 15px !important; |
