diff --git a/common.php b/common.php index 3babc23..a89adca 100644 --- a/common.php +++ b/common.php @@ -4,6 +4,7 @@ require __DIR__.'/check_setup.php'; require __DIR__.'/vendor/PicoTools/Translator.php'; require __DIR__.'/vendor/PicoDb/Database.php'; require __DIR__.'/vendor/PicoFeed/Client.php'; +require __DIR__.'/vendor/PicoFeed/Parser.php'; require __DIR__.'/models/config.php'; require __DIR__.'/models/user.php'; require __DIR__.'/models/feed.php'; diff --git a/helpers.php b/helpers.php index 3ef7495..62afba8 100644 --- a/helpers.php +++ b/helpers.php @@ -2,6 +2,11 @@ namespace Helper; +function isRTL($language) +{ + return \PicoFeed\Parser::isLanguageRTL($language); +} + function css() { $theme = \Model\Config\get('theme'); @@ -17,3 +22,240 @@ function css() return 'assets/css/app.css?version='.filemtime('assets/css/app.css'); } + +function get_current_base_url() +{ + $url = isset($_SERVER['HTTPS']) ? 'https://' : 'http://'; + $url .= $_SERVER['SERVER_NAME']; + $url .= $_SERVER['SERVER_PORT'] == 80 || $_SERVER['SERVER_PORT'] == 443 ? '' : ':'.$_SERVER['SERVER_PORT']; + $url .= dirname($_SERVER['PHP_SELF']) !== '/' ? dirname($_SERVER['PHP_SELF']).'/' : '/'; + + return $url; +} + +function escape($value) +{ + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false); +} + +function flash($html) +{ + $data = ''; + + if (isset($_SESSION['flash_message'])) { + $data = sprintf($html, escape($_SESSION['flash_message'])); + unset($_SESSION['flash_message']); + } + + return $data; +} + +function flash_error($html) +{ + $data = ''; + + if (isset($_SESSION['flash_error_message'])) { + $data = sprintf($html, escape($_SESSION['flash_error_message'])); + unset($_SESSION['flash_error_message']); + } + + return $data; +} + +function format_bytes($size, $precision = 2) +{ + $base = log($size) / log(1024); + $suffixes = array('', 'k', 'M', 'G', 'T'); + + return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)]; +} + +function get_host_from_url($url) +{ + return escape(parse_url($url, PHP_URL_HOST)) ?: $url; +} + +function summary($value, $min_length = 5, $max_length = 120, $end = '[...]') +{ + $length = strlen($value); + + if ($length > $max_length) { + return substr($value, 0, strpos($value, ' ', $max_length)).' '.$end; + } + else if ($length < $min_length) { + return ''; + } + + return $value; +} + +function in_list($id, array $listing) +{ + if (isset($listing[$id])) { + return escape($listing[$id]); + } + + return '?'; +} + +function relative_time($timestamp, $fallback_date_format = '%e %B %Y %k:%M') +{ + $diff = time() - $timestamp; + + if ($diff < 60) return \t('%d second'.($diff > 1 ? 's' : '').' ago', $diff); + + $diff = floor($diff / 60); + if ($diff < 60) return \t('%d minute'.($diff > 1 ? 's' : '').' ago', $diff); + + $diff = floor($diff / 60); + if ($diff < 24) return \t('%d hour'.($diff > 1 ? 's' : '').' ago', $diff); + + $diff = floor($diff / 24); + if ($diff < 7) return \t('%d day'.($diff > 1 ? 's' : '').' ago', $diff); + + $diff = floor($diff / 7); + if ($diff < 4) return \t('%d week'.($diff > 1 ? 's' : '').' ago', $diff); + + $diff = floor($diff / 4); + if ($diff < 12) return \t('%d month'.($diff > 1 ? 's' : '').' ago', $diff); + + return \dt($fallback_date_format, $timestamp); +} + +function error_class(array $errors, $name) +{ + return ! isset($errors[$name]) ? '' : ' form-error'; +} + +function error_list(array $errors, $name) +{ + $html = ''; + + if (isset($errors[$name])) { + + $html .= ''; + } + + return $html; +} + +function form_value($values, $name) +{ + if (isset($values->$name)) { + return 'value="'.escape($values->$name).'"'; + } + + return isset($values[$name]) ? 'value="'.escape($values[$name]).'"' : ''; +} + +function form_hidden($name, $values = array()) +{ + return ''; +} + +function form_default_select($name, array $options, $values = array(), array $errors = array(), $class = '') +{ + $options = array('' => '?') + $options; + return form_select($name, $options, $values, $errors, $class); +} + +function form_select($name, array $options, $values = array(), array $errors = array(), $class = '') +{ + $html = ''; + $html .= error_list($errors, $name); + + return $html; +} + +function form_radios($name, array $options, array $values = array()) +{ + $html = ''; + + foreach ($options as $value => $label) { + $html .= form_radio($name, $label, $value, isset($values[$name]) && $values[$name] == $value); + } + + return $html; +} + +function form_radio($name, $label, $value, $selected = false, $class = '') +{ + return ''; +} + +function form_checkbox($name, $label, $value, $checked = false, $class = '') +{ + return ''; +} + +function form_label($label, $name, $class = '') +{ + return ''; +} + +function form_textarea($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + $class .= error_class($errors, $name); + + $html = ''; + $html .= error_list($errors, $name); + + return $html; +} + +function form_input($type, $name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + $class .= error_class($errors, $name); + + $html = ''; + $html .= error_list($errors, $name); + + return $html; +} + +function form_text($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + return form_input('text', $name, $values, $errors, $attributes, $class); +} + +function form_password($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + return form_input('password', $name, $values, $errors, $attributes, $class); +} + +function form_email($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + return form_input('email', $name, $values, $errors, $attributes, $class); +} + +function form_date($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + return form_input('date', $name, $values, $errors, $attributes, $class); +} + +function form_number($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '') +{ + return form_input('number', $name, $values, $errors, $attributes, $class); +} diff --git a/index.php b/index.php index 240f11b..03a2fc6 100644 --- a/index.php +++ b/index.php @@ -2,7 +2,6 @@ require __DIR__.'/common.php'; require __DIR__.'/vendor/PicoTools/Template.php'; -require __DIR__.'/vendor/PicoTools/Helper.php'; require __DIR__.'/vendor/PicoFarad/Response.php'; require __DIR__.'/vendor/PicoFarad/Request.php'; require __DIR__.'/vendor/PicoFarad/Session.php'; diff --git a/models/config.php b/models/config.php index 3164469..967e532 100644 --- a/models/config.php +++ b/models/config.php @@ -16,7 +16,7 @@ use SimpleValidator\Validator; use SimpleValidator\Validators; use PicoDb\Database; -const DB_VERSION = 22; +const DB_VERSION = 23; const HTTP_USERAGENT = 'Miniflux - http://miniflux.net'; const HTTP_FAKE_USERAGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.62 Safari/537.36'; diff --git a/models/item.php b/models/item.php index b645fd7..12b7560 100644 --- a/models/item.php +++ b/models/item.php @@ -24,6 +24,7 @@ function get_everything() 'items.feed_id', 'items.status', 'items.content', + 'items.language', 'feeds.site_url', 'feeds.title AS feed_title' ) @@ -49,6 +50,7 @@ function get_everything_since($timestamp) 'items.feed_id', 'items.status', 'items.content', + 'items.language', 'feeds.site_url', 'feeds.title AS feed_title' ) @@ -85,6 +87,7 @@ function get_all($status, $offset = null, $limit = null, $order_column = 'update 'items.feed_id', 'items.status', 'items.content', + 'items.language', 'feeds.site_url', 'feeds.title AS feed_title' ) @@ -131,6 +134,7 @@ function get_bookmarks($offset = null, $limit = null) 'items.status', 'items.content', 'items.feed_id', + 'items.language', 'feeds.site_url', 'feeds.title AS feed_title' ) @@ -169,6 +173,7 @@ function get_all_by_feed($feed_id, $offset = null, $limit = null, $order_column 'items.status', 'items.content', 'items.bookmark', + 'items.language', 'feeds.site_url' ) ->join('feeds', 'id', 'feed_id') @@ -433,6 +438,7 @@ function update_all($feed_id, array $items, $grabber = false) 'feed_id' => $feed_id, 'enclosure' => isset($item->enclosure) ? $item->enclosure : null, 'enclosure_type' => isset($item->enclosure_type) ? $item->enclosure_type : null, + 'language' => $item->language, )); } else { diff --git a/models/schema.php b/models/schema.php index a8a9934..aaadc24 100644 --- a/models/schema.php +++ b/models/schema.php @@ -2,6 +2,13 @@ namespace Schema; + +function version_23($pdo) +{ + $pdo->exec('ALTER TABLE items ADD COLUMN language TEXT'); +} + + function version_22($pdo) { $pdo->exec("ALTER TABLE config ADD COLUMN timezone TEXT DEFAULT 'UTC'"); diff --git a/templates/item.php b/templates/item.php index 6add348..a1b3b81 100644 --- a/templates/item.php +++ b/templates/item.php @@ -6,7 +6,7 @@ data-item-page="" > -

+

> ★ ' : '' ?> ✔ ' : '' ?>

-

+

>

-
+
> diff --git a/vendor/PicoFeed/Clients/Stream.php b/vendor/PicoFeed/Clients/Stream.php index eaa610f..e004b2f 100644 --- a/vendor/PicoFeed/Clients/Stream.php +++ b/vendor/PicoFeed/Clients/Stream.php @@ -4,14 +4,27 @@ namespace PicoFeed\Clients; use \PicoFeed\Logging; +/** + * Stream context HTTP client + * + * @author Frederic Guillot + * @package client + */ class Stream extends \PicoFeed\Client { + /** + * Do the HTTP request + * + * @access public + * @return array HTTP response ['body' => ..., 'status' => ..., 'headers' => ...] + */ public function doRequest() { // Prepare HTTP headers for the request $headers = array( 'Connection: close', 'User-Agent: '.$this->user_agent, + 'Accept-Encoding: gzip', ); if ($this->etag) $headers[] = 'If-None-Match: '.$this->etag; @@ -61,6 +74,10 @@ class Stream extends \PicoFeed\Client $body = $this->decodeChunked($body); } + if (isset($headers['Content-Encoding']) && $headers['Content-Encoding'] === 'gzip') { + $body = gzdecode($body); + } + return array( 'status' => $status, 'body' => $body, @@ -68,7 +85,13 @@ class Stream extends \PicoFeed\Client ); } - + /** + * Decode a chunked body + * + * @access public + * @param string $str Raw body + * @return string Decoded body + */ public function decodeChunked($str) { for ($result = ''; ! empty($str); $str = trim($str)) { @@ -84,4 +107,4 @@ class Stream extends \PicoFeed\Client return $result; } -} \ No newline at end of file +} diff --git a/vendor/PicoFeed/Import.php b/vendor/PicoFeed/Import.php index 76469bf..096dffc 100644 --- a/vendor/PicoFeed/Import.php +++ b/vendor/PicoFeed/Import.php @@ -2,6 +2,8 @@ namespace PicoFeed; +require_once __DIR__.'/Logging.php'; + class Import { private $content = ''; diff --git a/vendor/PicoFeed/Parser.php b/vendor/PicoFeed/Parser.php index d96aa89..5175cab 100644 --- a/vendor/PicoFeed/Parser.php +++ b/vendor/PicoFeed/Parser.php @@ -7,24 +7,69 @@ require_once __DIR__.'/Filter.php'; require_once __DIR__.'/Encoding.php'; require_once __DIR__.'/Grabber.php'; +/** + * Base parser class + * + * @author Frederic Guillot + * @package parser + */ abstract class Parser { + /** + * Hash algorithm used to generate item id, any value supported by PHP, see hash_algos() + * + * @access public + * @static + * @var string + */ + public static $hashAlgo = 'crc32b'; // crc32b seems to be faster and shorter than other hash algorithms + + /** + * Feed content (XML data) + * + * @access protected + * @var string + */ protected $content = ''; + /** + * Feed properties (values parsed) + * + * @access public + */ public $id = ''; public $url = ''; public $title = ''; public $updated = ''; + public $language = ''; public $items = array(); + + /** + * Content grabber parameters + * + * @access public + */ public $grabber = false; public $grabber_ignore_urls = array(); public $grabber_timeout = null; public $grabber_user_agent = null; - + /** + * Parse feed content + * + * @abstract + * @access public + * @return mixed + */ abstract public function execute(); - + /** + * Constructor + * + * @access public + * @param string $content Feed content + * @param string $http_encoding HTTP encoding (headers) + */ public function __construct($content, $http_encoding = '') { $xml_encoding = Filter::getEncodingFromXmlTag($content); @@ -45,7 +90,14 @@ abstract class Parser $this->content = $this->normalizeData($this->content); } - + /** + * Filter HTML for entry content + * + * @access public + * @param string $item_content Item content + * @param string $item_url Item URL + * @return string Filtered content + */ public function filterHtml($item_content, $item_url) { $content = ''; @@ -64,7 +116,12 @@ abstract class Parser return $content; } - + /** + * Get XML parser errors + * + * @access public + * @return string + */ public function getXmlErrors() { $errors = array(); @@ -82,8 +139,13 @@ abstract class Parser return implode(', ', $errors); } - - // Dirty quickfix before XML parsing + /** + * Dirty quickfixes before XML parsing + * + * @access public + * @param string $data Raw data + * @return string Normalized data + */ public function normalizeData($data) { $data = str_replace("\xc3\x20", '', $data); @@ -91,8 +153,13 @@ abstract class Parser return $data; } - // For each href attribute, replace & by & - // Useful for broken XML feeds + /** + * Replace & by & for each href attribute (Fix broken feeds) + * + * @access public + * @param string $content Raw data + * @return string Normalized data + */ public function replaceEntityAttribute($content) { $content = preg_replace_callback('/href="[^"]+"/', function(array $matches) { @@ -102,8 +169,13 @@ abstract class Parser return $content; } - - // Trim whitespace from the begining, the end and inside a string and don't break utf-8 string + /** + * Trim whitespace from the begining, the end and inside a string and don't break utf-8 string + * + * @access public + * @param string $value Raw data + * @return string Normalized data + */ public function stripWhiteSpace($value) { $value = str_replace("\r", "", $value); @@ -112,14 +184,25 @@ abstract class Parser return trim($value); } - + /** + * Generate a unique id for an entry (hash all arguments) + * + * @access public + * @param string $args Pieces of data to hash + * @return string Id + */ public function generateId() { - // crc32b seems to be faster and shorter than other hash algorithms - return hash('crc32b', implode(func_get_args())); + return hash(self::$hashAlgo, implode(func_get_args())); } - + /** + * Try to parse all date format for broken feeds + * + * @access public + * @param string $value Original date format + * @return integer Timestamp + */ public function parseDate($value) { // Format => truncate to this length if not null @@ -168,7 +251,14 @@ abstract class Parser return time(); } - + /** + * Get a valid date from a given format + * + * @access public + * @param string $format Date format + * @param string $value Original date value + * @return integer Timestamp + */ public function getValidDate($format, $value) { $date = \DateTime::createFromFormat($format, $value); @@ -181,8 +271,13 @@ abstract class Parser return 0; } - - // Hardcoded list of hostname/token to exclude from id generation + /** + * Hardcoded list of hostname/token to exclude from id generation + * + * @access public + * @param string $url URL + * @return boolean + */ public function isExcludedFromId($url) { $exclude_list = array('ap.org', 'jacksonville.com'); @@ -193,4 +288,59 @@ abstract class Parser return false; } + + /** + * Get xml:lang value + * + * @access public + * @param string $xml XML string + * @return string Language + */ + public function getXmlLang($xml) + { + $dom = new \DOMDocument; + $dom->loadXML($this->content); + + $xpath = new \DOMXPath($dom); + return $xpath->evaluate('string(//@xml:lang[1])') ?: ''; + } + + /** + * Return true if the given language is "Right to Left" + * + * @static + * @access public + * @param string $language Language: fr-FR, en-US + * @return bool + */ + public static function isLanguageRTL($language) + { + $language = strtolower($language); + + // Arabic (ar-**) + if (strpos($language, 'ar') === 0) return true; + + // Farsi (fa-**) + if (strpos($language, 'fa') === 0) return true; + + // Urdu (ur-**) + if (strpos($language, 'ur') === 0) return true; + + // Pashtu (ps-**) + if (strpos($language, 'ps') === 0) return true; + + // Syriac (syr-**) + if (strpos($language, 'syr') === 0) return true; + + // Divehi (dv-**) + if (strpos($language, 'dv') === 0) return true; + + // Hebrew (he-**) + if (strpos($language, 'he') === 0) return true; + + // Yiddish (yi-**) + if (strpos($language, 'yi') === 0) return true; + + return false; + } } diff --git a/vendor/PicoFeed/Parsers/Atom.php b/vendor/PicoFeed/Parsers/Atom.php index b483cac..1d068e1 100644 --- a/vendor/PicoFeed/Parsers/Atom.php +++ b/vendor/PicoFeed/Parsers/Atom.php @@ -2,8 +2,20 @@ namespace PicoFeed\Parsers; +/** + * Atom parser + * + * @author Frederic Guillot + * @package parser + */ class Atom extends \PicoFeed\Parser { + /** + * Parse the document + * + * @access public + * @return mixed Atom instance or false + */ public function execute() { \PicoFeed\Logging::log(\get_called_class().': begin parsing'); @@ -17,6 +29,7 @@ class Atom extends \PicoFeed\Parser return false; } + $this->language = $this->getXmlLang($this->content); $this->url = $this->getUrl($xml); $this->title = $this->stripWhiteSpace((string) $xml->title) ?: $this->url; $this->id = (string) $xml->id; @@ -41,6 +54,7 @@ class Atom extends \PicoFeed\Parser $item->updated = $this->parseDate((string) $entry->updated); $item->author = $author; $item->content = $this->filterHtml($this->getContent($entry), $item->url); + $item->language = $this->language; if (empty($item->title)) $item->title = $item->url; @@ -65,7 +79,13 @@ class Atom extends \PicoFeed\Parser return $this; } - + /** + * Get the entry content + * + * @access public + * @param SimpleXMLElement $entry XML Entry + * @return string + */ public function getContent($entry) { if (isset($entry->content) && ! empty($entry->content)) { @@ -84,7 +104,13 @@ class Atom extends \PicoFeed\Parser return ''; } - + /** + * Get the URL from a link tag + * + * @access public + * @param SimpleXMLElement $xml XML tag + * @return string + */ public function getUrl($xml) { foreach ($xml->link as $link) { diff --git a/vendor/PicoFeed/Parsers/Rss20.php b/vendor/PicoFeed/Parsers/Rss20.php index c871c59..8feb985 100644 --- a/vendor/PicoFeed/Parsers/Rss20.php +++ b/vendor/PicoFeed/Parsers/Rss20.php @@ -2,8 +2,20 @@ namespace PicoFeed\Parsers; +/** + * RSS 2.0 Parser + * + * @author Frederic Guillot + * @package parser + */ class Rss20 extends \PicoFeed\Parser { + /** + * Parse the document + * + * @access public + * @return mixed Rss20 instance or false + */ public function execute() { \PicoFeed\Logging::log(\get_called_class().': begin parsing'); @@ -26,7 +38,6 @@ class Rss20 extends \PicoFeed\Parser $link = (string) $xml_link; if ($link !== '') { - $this->url = (string) $link; break; } @@ -37,6 +48,7 @@ class Rss20 extends \PicoFeed\Parser $this->url = (string) $xml->channel->link; } + $this->language = isset($xml->channel->language) ? (string) $xml->channel->language : ''; $this->title = $this->stripWhiteSpace((string) $xml->channel->title) ?: $this->url; $this->id = $this->url; $this->updated = $this->parseDate(isset($xml->channel->pubDate) ? (string) $xml->channel->pubDate : (string) $xml->channel->lastBuildDate); @@ -60,6 +72,7 @@ class Rss20 extends \PicoFeed\Parser $item->content = ''; $item->enclosure = ''; $item->enclosure_type = ''; + $item->language = $this->language; foreach ($namespaces as $name => $url) { @@ -94,22 +107,18 @@ class Rss20 extends \PicoFeed\Parser if (empty($item->author)) { if (isset($entry->author)) { - $item->author = (string) $entry->author; } else if (isset($xml->channel->webMaster)) { - $item->author = (string) $xml->channel->webMaster; } } if (isset($entry->guid) && isset($entry->guid['isPermaLink']) && (string) $entry->guid['isPermaLink'] != 'false') { - $id = (string) $entry->guid; $item->id = $this->generateId($id !== '' && $id !== $item->url ? $id : $item->url, $this->isExcludedFromId($this->url) ? '' : $this->url); } else { - $item->id = $this->generateId($item->url, $this->isExcludedFromId($this->url) ? '' : $this->url); }