Add RTL language support

This commit is contained in:
Frédéric Guillot 2014-03-16 21:35:57 -04:00
parent 056673b70c
commit e6e6db71f8
13 changed files with 497 additions and 32 deletions

View File

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

View File

@ -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 .= '<ul class="form-errors">';
foreach ($errors[$name] as $error) {
$html .= '<li>'.escape($error).'</li>';
}
$html .= '</ul>';
}
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 '<input type="hidden" name="'.$name.'" id="form-'.$name.'" '.form_value($values, $name).'/>';
}
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 = '<select name="'.$name.'" id="form-'.$name.'" class="'.$class.'">';
foreach ($options as $id => $value) {
$html .= '<option value="'.escape($id).'"';
if (isset($values->$name) && $id == $values->$name) $html .= ' selected="selected"';
if (isset($values[$name]) && $id == $values[$name]) $html .= ' selected="selected"';
$html .= '>'.escape($value).'</option>';
}
$html .= '</select>';
$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 '<label><input type="radio" name="'.$name.'" class="'.$class.'" value="'.escape($value).'" '.($selected ? 'selected="selected"' : '').'>'.escape($label).'</label>';
}
function form_checkbox($name, $label, $value, $checked = false, $class = '')
{
return '<label><input type="checkbox" name="'.$name.'" class="'.$class.'" value="'.escape($value).'" '.($checked ? 'checked="checked"' : '').'>&nbsp;'.escape($label).'</label>';
}
function form_label($label, $name, $class = '')
{
return '<label for="form-'.$name.'" class="'.$class.'">'.escape($label).'</label>';
}
function form_textarea($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
{
$class .= error_class($errors, $name);
$html = '<textarea name="'.$name.'" id="form-'.$name.'" class="'.$class.'" ';
$html .= implode(' ', $attributes).'>';
$html .= isset($values->$name) ? escape($values->$name) : isset($values[$name]) ? $values[$name] : '';
$html .= '</textarea>';
$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 = '<input type="'.$type.'" name="'.$name.'" id="form-'.$name.'" '.form_value($values, $name).' class="'.$class.'" ';
$html .= implode(' ', $attributes).'/>';
$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);
}

View File

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

View File

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

View File

@ -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 {

View File

@ -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'");

View File

@ -6,7 +6,7 @@
data-item-page="<?= $menu ?>"
<?= $hide ? 'data-hide="true"' : '' ?>
>
<h2>
<h2 <?= Helper\isRTL($item['language']) ? 'dir="rtl"' : '' ?>>
<?= $item['bookmark'] ? '<span id="bookmark-icon-'.$item['id'].'">★ </span>' : '' ?>
<?= $item['status'] === 'read' ? '<span id="read-icon-'.$item['id'].'">✔ </span>' : '' ?>
<a
@ -18,7 +18,7 @@
<?= Helper\escape($item['title']) ?>
</a>
</h2>
<p class="preview">
<p class="preview" <?= Helper\isRTL($item['language']) ? 'dir="rtl"' : '' ?>>
<?= Helper\escape(Helper\summary(strip_tags($item['content']), 50, 300)) ?>
</p>
<ul class="item-menu">

View File

@ -30,7 +30,7 @@
</nav>
<?php endif ?>
<h1>
<h1 <?= Helper\isRTL($item['language']) ? 'dir="rtl"' : '' ?>>
<a href="<?= $item['url'] ?>" rel="noreferrer" target="_blank" id="original-<?= $item['id'] ?>">
<?= Helper\escape($item['title']) ?>
</a>
@ -82,7 +82,7 @@
</li>
</ul>
<div id="item-content">
<div id="item-content" <?= Helper\isRTL($item['language']) ? 'dir="rtl"' : '' ?>>
<?= $item['content'] ?>
<?php if ($item['enclosure']): ?>

View File

@ -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)) {

View File

@ -2,6 +2,8 @@
namespace PicoFeed;
require_once __DIR__.'/Logging.php';
class Import
{
private $content = '';

View File

@ -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 &amp;
// Useful for broken XML feeds
/**
* Replace & by &amp; 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;
}
}

View File

@ -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) {

View File

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