<?php
+
+require_once 'exceptions/IOException.php';
+
/**
- * Exception class thrown when a filesystem access failure happens
+ * Class FileUtils
+ *
+ * Utility class for file manipulation.
*/
-class IOException extends Exception
+class FileUtils
{
- private $path;
+ /**
+ * @var string
+ */
+ protected static $phpPrefix = '<?php /* ';
+
+ /**
+ * @var string
+ */
+ protected static $phpSuffix = ' */ ?>';
/**
- * Construct a new IOException
+ * Write data into a file (Shaarli database format).
+ * The data is stored in a PHP file, as a comment, in compressed base64 format.
+ *
+ * The file will be created if it doesn't exist.
+ *
+ * @param string $file File path.
+ * @param string $content Content to write.
+ *
+ * @return int|bool Number of bytes written or false if it fails.
*
- * @param string $path path to the resource that cannot be accessed
- * @param string $message Custom exception message.
+ * @throws IOException The destination file can't be written.
*/
- public function __construct($path, $message = '')
+ public static function writeFlatDB($file, $content)
{
- $this->path = $path;
- $this->message = empty($message) ? 'Error accessing' : $message;
- $this->message .= PHP_EOL . $this->path;
+ if (is_file($file) && !is_writeable($file)) {
+ // The datastore exists but is not writeable
+ throw new IOException($file);
+ } else if (!is_file($file) && !is_writeable(dirname($file))) {
+ // The datastore does not exist and its parent directory is not writeable
+ throw new IOException(dirname($file));
+ }
+
+ return file_put_contents(
+ $file,
+ self::$phpPrefix.base64_encode(gzdeflate(serialize($content))).self::$phpSuffix
+ );
+ }
+
+ /**
+ * Read data from a file containing Shaarli database format content.
+ * If the file isn't readable or doesn't exists, default data will be returned.
+ *
+ * @param string $file File path.
+ * @param mixed $default The default value to return if the file isn't readable.
+ *
+ * @return mixed The content unserialized, or default if the file isn't readable, or false if it fails.
+ */
+ public static function readFlatDB($file, $default = null)
+ {
+ // Note that gzinflate is faster than gzuncompress.
+ // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
+ if (is_readable($file)) {
+ return unserialize(
+ gzinflate(
+ base64_decode(
+ substr(file_get_contents($file), strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
+ )
+ )
+ );
+ }
+
+ return $default;
}
}
--- /dev/null
+<?php
+
+/**
+ * Class History
+ *
+ * Handle the history file tracing events in Shaarli.
+ * The history is stored as JSON in a file set by 'resource.history' setting.
+ *
+ * Available data:
+ * - event: event key
+ * - datetime: event date, in ISO8601 format.
+ * - id: event item identifier (currently only link IDs).
+ *
+ * Available event keys:
+ * - CREATED: new link
+ * - UPDATED: link updated
+ * - DELETED: link deleted
+ * - SETTINGS: the settings have been updated through the UI.
+ *
+ * Note: new events are put at the beginning of the file and history array.
+ */
+class History
+{
+ /**
+ * @var string Action key: a new link has been created.
+ */
+ const CREATED = 'CREATED';
+
+ /**
+ * @var string Action key: a link has been updated.
+ */
+ const UPDATED = 'UPDATED';
+
+ /**
+ * @var string Action key: a link has been deleted.
+ */
+ const DELETED = 'DELETED';
+
+ /**
+ * @var string Action key: settings have been updated.
+ */
+ const SETTINGS = 'SETTINGS';
+
+ /**
+ * @var string History file path.
+ */
+ protected $historyFilePath;
+
+ /**
+ * @var array History data.
+ */
+ protected $history;
+
+ /**
+ * @var int History retention time in seconds (1 month).
+ */
+ protected $retentionTime = 2678400;
+
+ /**
+ * History constructor.
+ *
+ * @param string $historyFilePath History file path.
+ * @param int $retentionTime History content rentention time in seconds.
+ *
+ * @throws Exception if something goes wrong.
+ */
+ public function __construct($historyFilePath, $retentionTime = null)
+ {
+ $this->historyFilePath = $historyFilePath;
+ if ($retentionTime !== null) {
+ $this->retentionTime = $retentionTime;
+ }
+ }
+
+ /**
+ * Initialize: read history file.
+ *
+ * Allow lazy loading (don't read the file if it isn't necessary).
+ */
+ protected function initialize()
+ {
+ $this->check();
+ $this->read();
+ }
+
+ /**
+ * Add Event: new link.
+ *
+ * @param array $link Link data.
+ */
+ public function addLink($link)
+ {
+ $this->addEvent(self::CREATED, $link['id']);
+ }
+
+ /**
+ * Add Event: update existing link.
+ *
+ * @param array $link Link data.
+ */
+ public function updateLink($link)
+ {
+ $this->addEvent(self::UPDATED, $link['id']);
+ }
+
+ /**
+ * Add Event: delete existing link.
+ *
+ * @param array $link Link data.
+ */
+ public function deleteLink($link)
+ {
+ $this->addEvent(self::DELETED, $link['id']);
+ }
+
+ /**
+ * Add Event: settings updated.
+ */
+ public function updateSettings()
+ {
+ $this->addEvent(self::SETTINGS);
+ }
+
+ /**
+ * Save a new event and write it in the history file.
+ *
+ * @param string $status Event key, should be defined as constant.
+ * @param mixed $id Event item identifier (e.g. link ID).
+ */
+ protected function addEvent($status, $id = null)
+ {
+ if ($this->history === null) {
+ $this->initialize();
+ }
+
+ $item = [
+ 'event' => $status,
+ 'datetime' => (new DateTime())->format(DateTime::ATOM),
+ 'id' => $id !== null ? $id : '',
+ ];
+ $this->history = array_merge([$item], $this->history);
+ $this->write();
+ }
+
+ /**
+ * Check that the history file is writable.
+ * Create the file if it doesn't exist.
+ *
+ * @throws Exception if it isn't writable.
+ */
+ protected function check()
+ {
+ if (! is_file($this->historyFilePath)) {
+ FileUtils::writeFlatDB($this->historyFilePath, []);
+ }
+
+ if (! is_writable($this->historyFilePath)) {
+ throw new Exception('History file isn\'t readable or writable');
+ }
+ }
+
+ /**
+ * Read JSON history file.
+ */
+ protected function read()
+ {
+ $this->history = FileUtils::readFlatDB($this->historyFilePath, []);
+ if ($this->history === false) {
+ throw new Exception('Could not parse history file');
+ }
+ }
+
+ /**
+ * Write JSON history file and delete old entries.
+ */
+ protected function write()
+ {
+ $comparaison = new DateTime('-'. $this->retentionTime . ' seconds');
+ foreach ($this->history as $key => $value) {
+ if (DateTime::createFromFormat(DateTime::ATOM, $value['datetime']) < $comparaison) {
+ unset($this->history[$key]);
+ }
+ }
+ FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
+ }
+
+ /**
+ * Get the History.
+ *
+ * @return array
+ */
+ public function getHistory()
+ {
+ if ($this->history === null) {
+ $this->initialize();
+ }
+
+ return $this->history;
+ }
+}
// Link date storage format
const LINK_DATE_FORMAT = 'Ymd_His';
- // Datastore PHP prefix
- protected static $phpPrefix = '<?php /* ';
-
- // Datastore PHP suffix
- protected static $phpSuffix = ' */ ?>';
-
// List of links (associative array)
// - key: link date (e.g. "20110823_124546"),
// - value: associative array (keys: title, description...)
if (!isset($value['id']) || empty($value['url'])) {
die('Internal Error: A link should always have an id and URL.');
}
- if ((! empty($offset) && ! is_int($offset)) || ! is_int($value['id'])) {
+ if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
die('You must specify an integer as a key.');
}
- if (! empty($offset) && $offset !== $value['id']) {
+ if ($offset !== null && $offset !== $value['id']) {
die('Array offset and link ID must be equal.');
}
return;
}
- // Read data
- // Note that gzinflate is faster than gzuncompress.
- // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
- $this->links = array();
-
- if (file_exists($this->datastore)) {
- $this->links = unserialize(gzinflate(base64_decode(
- substr(file_get_contents($this->datastore),
- strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
- }
+ $this->links = FileUtils::readFlatDB($this->datastore, []);
$toremove = array();
foreach ($this->links as $key => &$link) {
*/
private function write()
{
- if (is_file($this->datastore) && !is_writeable($this->datastore)) {
- // The datastore exists but is not writeable
- throw new IOException($this->datastore);
- } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
- // The datastore does not exist and its parent directory is not writeable
- throw new IOException(dirname($this->datastore));
- }
-
- file_put_contents(
- $this->datastore,
- self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
- );
-
+ FileUtils::writeFlatDB($this->datastore, $this->links);
}
/**
* @param array $files Server $_FILES parameters
* @param LinkDB $linkDb Loaded LinkDB instance
* @param ConfigManager $conf instance
+ * @param History $history History instance
*
* @return string Summary of the bookmark import status
*/
- public static function import($post, $files, $linkDb, $conf)
+ public static function import($post, $files, $linkDb, $conf, $history)
{
$filename = $files['filetoupload']['name'];
$filesize = $files['filetoupload']['size'];
$linkDb[$existingLink['id']] = $newLink;
$importCount++;
$overwriteCount++;
+ $history->updateLink($newLink);
continue;
}
$newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
$linkDb[$newLink['id']] = $newLink;
$importCount++;
+ $history->addLink($newLink);
}
$linkDb->save($conf->get('resource.page_cache'));
<?php
+use Shaarli\Config\ConfigManager;
+
/**
* This class is in charge of building the final page.
* (This is basically a wrapper around RainTPL which pre-fills some fields.)
<?php
/**
- * Generates the timezone selection form and JavaScript.
+ * Generates a list of available timezone continents and cities.
*
- * Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option
+ * Two distinct array based on available timezones
+ * and the one selected in the settings:
+ * - (0) continents:
+ * + list of available continents
+ * + special key 'selected' containing the value of the selected timezone's continent
+ * - (1) cities:
+ * + list of available cities associated with their continent
+ * + special key 'selected' containing the value of the selected timezone's city (without the continent)
*
- * Example: preselect Europe/Paris
- * list($htmlform, $js) = generateTimeZoneForm('Europe/Paris');
+ * Example:
+ * [
+ * [
+ * 'America',
+ * 'Europe',
+ * 'selected' => 'Europe',
+ * ],
+ * [
+ * ['continent' => 'America', 'city' => 'Toronto'],
+ * ['continent' => 'Europe', 'city' => 'Paris'],
+ * 'selected' => 'Paris',
+ * ],
+ * ];
*
+ * Notes:
+ * - 'UTC/UTC' is mapped to 'UTC' to form a valid option
+ * - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
+ * - these arrays are designed to build timezone selects in template files with any HTML structure
+ *
+ * @param array $installedTimeZones List of installed timezones as string
* @param string $preselectedTimezone preselected timezone (optional)
*
- * @return array containing the generated HTML form and Javascript code
+ * @return array[] continents and cities
**/
-function generateTimeZoneForm($preselectedTimezone='')
+function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
{
- // Select the server timezone
- if ($preselectedTimezone == '') {
- $preselectedTimezone = date_default_timezone_get();
- }
-
if ($preselectedTimezone == 'UTC') {
$pcity = $pcontinent = 'UTC';
} else {
$pcity = substr($preselectedTimezone, $spos+1);
}
- // The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires'
- // We split the list in continents/cities.
- $continents = array();
- $cities = array();
-
- // TODO: use a template to generate the HTML/Javascript form
-
- foreach (timezone_identifiers_list() as $tz) {
+ $continents = [];
+ $cities = [];
+ foreach ($installedTimeZones as $tz) {
if ($tz == 'UTC') {
$tz = 'UTC/UTC';
}
$spos = strpos($tz, '/');
- if ($spos !== false) {
- $continent = substr($tz, 0, $spos);
- $city = substr($tz, $spos+1);
- $continents[$continent] = 1;
-
- if (!isset($cities[$continent])) {
- $cities[$continent] = '';
- }
- $cities[$continent] .= '<option value="'.$city.'"';
- if ($pcity == $city) {
- $cities[$continent] .= ' selected="selected"';
- }
- $cities[$continent] .= '>'.$city.'</option>';
+ // Ignore invalid timezones
+ if ($spos === false) {
+ continue;
}
- }
-
- $continentsHtml = '';
- $continents = array_keys($continents);
- foreach ($continents as $continent) {
- $continentsHtml .= '<option value="'.$continent.'"';
- if ($pcontinent == $continent) {
- $continentsHtml .= ' selected="selected"';
- }
- $continentsHtml .= '>'.$continent.'</option>';
+ $continent = substr($tz, 0, $spos);
+ $city = substr($tz, $spos+1);
+ $cities[] = ['continent' => $continent, 'city' => $city];
+ $continents[$continent] = true;
}
- // Timezone selection form
- $timezoneForm = 'Continent:';
- $timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">';
- $timezoneForm .= $continentsHtml.'</select>';
- $timezoneForm .= ' City:';
- $timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
-
- // Javascript handler - updates the city list when the user selects a continent
- $timezoneJs = '<script>';
- $timezoneJs .= 'function onChangecontinent() {';
- $timezoneJs .= 'document.getElementById("city").innerHTML =';
- $timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
- $timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
- $timezoneJs .= '</script>';
+ $continents = array_keys($continents);
+ $continents['selected'] = $pcontinent;
+ $cities['selected'] = $pcity;
- return array($timezoneForm, $timezoneJs);
+ return [$continents, $cities];
}
/**
return $formatter->format($date);
}
+
+/**
+ * Check if the input is an integer, no matter its real type.
+ *
+ * PHP is a bit messy regarding this:
+ * - is_int returns false if the input is a string
+ * - ctype_digit returns false if the input is an integer or negative
+ *
+ * @param mixed $input value
+ *
+ * @return bool true if the input is an integer, false otherwise
+ */
+function is_integer_mixed($input)
+{
+ if (is_array($input) || is_bool($input) || is_object($input)) {
+ return false;
+ }
+ $input = strval($input);
+ return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
+}
+
+/**
+ * Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
+ *
+ * @param string $val Size expressed in string.
+ *
+ * @return int Size expressed in bytes.
+ */
+function return_bytes($val)
+{
+ if (is_integer_mixed($val) || $val === '0' || empty($val)) {
+ return $val;
+ }
+ $val = trim($val);
+ $last = strtolower($val[strlen($val)-1]);
+ $val = intval(substr($val, 0, -1));
+ switch($last) {
+ case 'g': $val *= 1024;
+ case 'm': $val *= 1024;
+ case 'k': $val *= 1024;
+ }
+ return $val;
+}
+
+/**
+ * Return a human readable size from bytes.
+ *
+ * @param int $bytes value
+ *
+ * @return string Human readable size
+ */
+function human_bytes($bytes)
+{
+ if ($bytes === '') {
+ return t('Setting not set');
+ }
+ if (! is_integer_mixed($bytes)) {
+ return $bytes;
+ }
+ $bytes = intval($bytes);
+ if ($bytes === 0) {
+ return t('Unlimited');
+ }
+
+ $units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
+ for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
+ $bytes /= 1024;
+ }
+
+ return round($bytes) . $units[$i];
+}
+
+/**
+ * Try to determine max file size for uploads (POST).
+ * Returns an integer (in bytes) or formatted depending on $format.
+ *
+ * @param mixed $limitPost post_max_size PHP setting
+ * @param mixed $limitUpload upload_max_filesize PHP setting
+ * @param bool $format Format max upload size to human readable size
+ *
+ * @return int|string max upload file size
+ */
+function get_max_upload_size($limitPost, $limitUpload, $format = true)
+{
+ $size1 = return_bytes($limitPost);
+ $size2 = return_bytes($limitUpload);
+ // Return the smaller of two:
+ $maxsize = min($size1, $size2);
+ return $format ? human_bytes($maxsize) : $maxsize;
+}
$this->setEmpty('resource.updates', 'data/updates.txt');
$this->setEmpty('resource.log', 'data/log.txt');
$this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
+ $this->setEmpty('resource.history', 'data/history.php');
$this->setEmpty('resource.raintpl_tpl', 'tpl/');
$this->setEmpty('resource.theme', 'default');
$this->setEmpty('resource.raintpl_tmp', 'tmp/');
--- /dev/null
+<?php
+
+/**
+ * Exception class thrown when a filesystem access failure happens
+ */
+class IOException extends Exception
+{
+ private $path;
+
+ /**
+ * Construct a new IOException
+ *
+ * @param string $path path to the resource that cannot be accessed
+ * @param string $message Custom exception message.
+ */
+ public function __construct($path, $message = '')
+ {
+ $this->path = $path;
+ $this->message = empty($message) ? 'Error accessing' : $message;
+ $this->message .= ' "' . $this->path .'"';
+ }
+}
require_once 'application/config/ConfigPlugin.php';
require_once 'application/FeedBuilder.php';
require_once 'application/FileUtils.php';
+require_once 'application/History.php';
require_once 'application/HttpUtils.php';
require_once 'application/Languages.php';
require_once 'application/LinkDB.php';
}
}
-// ------------------------------------------------------------------------------------------
-// Misc utility functions:
-
-// Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
-function return_bytes($val)
-{
- $val = trim($val); $last=strtolower($val[strlen($val)-1]);
- switch($last)
- {
- case 'g': $val *= 1024;
- case 'm': $val *= 1024;
- case 'k': $val *= 1024;
- }
- return $val;
-}
-
-// Try to determine max file size for uploads (POST).
-// Returns an integer (in bytes)
-function getMaxFileSize()
-{
- $size1 = return_bytes(ini_get('post_max_size'));
- $size2 = return_bytes(ini_get('upload_max_filesize'));
- // Return the smaller of two:
- $maxsize = min($size1,$size2);
- // FIXME: Then convert back to readable notations ? (e.g. 2M instead of 2000000)
- return $maxsize;
-}
-
// ------------------------------------------------------------------------------------------
// Token management for XSRF protection
// Token should be used in any form which acts on data (create,update,delete,import...).
die($e->getMessage());
}
+ try {
+ $history = new History($conf->get('resource.history'));
+ } catch(Exception $e) {
+ die($e->getMessage());
+ }
+
$PAGE = new PageBuilder($conf);
$PAGE->assign('linkcount', count($LINKSDB));
$PAGE->assign('privateLinkcount', count_private($LINKSDB));
$conf->set('api.secret', escape($_POST['apiSecret']));
try {
$conf->write(isLoggedIn());
+ $history->updateSettings();
invalidateCaches($conf->get('resource.page_cache'));
}
catch(Exception $e) {
$PAGE->assign('theme', $conf->get('resource.theme'));
$PAGE->assign('theme_available', ThemeUtils::getThemes($conf->get('resource.raintpl_tpl')));
$PAGE->assign('redirector', $conf->get('redirector.url'));
- list($timezone_form, $timezone_js) = generateTimeZoneForm($conf->get('general.timezone'));
- $PAGE->assign('timezone_form', $timezone_form);
- $PAGE->assign('timezone_js',$timezone_js);
+ list($continents, $cities) = generateTimeZoneData(
+ timezone_identifiers_list(),
+ $conf->get('general.timezone')
+ );
+ $PAGE->assign('continents', $continents);
+ $PAGE->assign('cities', $cities);
$PAGE->assign('private_links_default', $conf->get('privacy.default_private_links', false));
$PAGE->assign('session_protection_disabled', $conf->get('security.session_protection_disabled', false));
$PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
$PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
$PAGE->assign('api_enabled', $conf->get('api.enabled', true));
$PAGE->assign('api_secret', $conf->get('api.secret'));
+ $history->updateSettings();
$PAGE->renderPage('configure');
exit;
}
unset($tags[array_search($needle,$tags)]); // Remove tag.
$value['tags']=trim(implode(' ',$tags));
$LINKSDB[$key]=$value;
+ $history->updateLink($LINKSDB[$key]);
}
$LINKSDB->save($conf->get('resource.page_cache'));
echo '<script>alert("Tag was removed from '.count($linksToAlter).' links.");document.location=\'?do=changetag\';</script>';
$tags[array_search($needle, $tags)] = trim($_POST['totag']);
$value['tags'] = implode(' ', array_unique($tags));
$LINKSDB[$key] = $value;
+ $history->updateLink($LINKSDB[$key]);
}
$LINKSDB->save($conf->get('resource.page_cache')); // Save to disk.
echo '<script>alert("Tag was renamed in '.count($linksToAlter).' links.");document.location=\'?searchtags='.urlencode(escape($_POST['totag'])).'\';</script>';
$created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate);
$updated = new DateTime();
$shortUrl = $LINKSDB[$id]['shorturl'];
+ $new = false;
} else {
// New link
$created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate);
$updated = null;
$shortUrl = link_small_hash($created, $id);
+ $new = true;
}
// Remove multiple spaces.
$LINKSDB[$id] = $link;
$LINKSDB->save($conf->get('resource.page_cache'));
+ if ($new) {
+ $history->addLink($link);
+ } else {
+ $history->updateLink($link);
+ }
// If we are called from the bookmarklet, we must close the popup:
if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
$pluginManager->executeHooks('delete_link', $link);
unset($LINKSDB[$id]);
$LINKSDB->save($conf->get('resource.page_cache')); // save to disk
+ $history->deleteLink($link);
// If we are called from the bookmarklet, we must close the popup:
if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { echo '<script>self.close();</script>'; exit; }
if (! isset($_POST['token']) || ! isset($_FILES['filetoupload'])) {
// Show import dialog
- $PAGE->assign('maxfilesize', getMaxFileSize());
+ $PAGE->assign(
+ 'maxfilesize',
+ get_max_upload_size(
+ ini_get('post_max_size'),
+ ini_get('upload_max_filesize'),
+ false
+ )
+ );
+ $PAGE->assign(
+ 'maxfilesizeHuman',
+ get_max_upload_size(
+ ini_get('post_max_size'),
+ ini_get('upload_max_filesize'),
+ true
+ )
+ );
$PAGE->renderPage('import');
exit;
}
// The file is too big or some form field may be missing.
echo '<script>alert("The file you are trying to upload is probably'
.' bigger than what this webserver can accept ('
- .getMaxFileSize().' bytes).'
+ .get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')).').'
.' Please upload in smaller chunks.");document.location=\'?do='
.Router::$PAGE_IMPORT .'\';</script>';
exit;
$_POST,
$_FILES,
$LINKSDB,
- $conf
+ $conf,
+ $history
);
echo '<script>alert("'.$status.'");document.location=\'?do='
.Router::$PAGE_IMPORT .'\';</script>';
// Plugin administration form action
if ($targetPage == Router::$PAGE_SAVE_PLUGINSADMIN) {
+ $history->updateSettings();
try {
if (isset($_POST['parameters_form'])) {
unset($_POST['parameters_form']);
exit;
}
- // Display config form:
- list($timezone_form, $timezone_js) = generateTimeZoneForm();
- $timezone_html = '';
- if ($timezone_form != '') {
- $timezone_html = '<tr><td><b>Timezone:</b></td><td>'.$timezone_form.'</td></tr>';
- }
-
$PAGE = new PageBuilder($conf);
- $PAGE->assign('timezone_html',$timezone_html);
- $PAGE->assign('timezone_js',$timezone_js);
+ list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
+ $PAGE->assign('continents', $continents);
+ $PAGE->assign('cities', $cities);
$PAGE->renderPage('install');
exit;
}
+++ /dev/null
-<span><a href="%s?url=%s"><img class="linklist-plugin-icon" src="%s/readityourself/book-open.png" title="Read with Readityourself" alt="readityourself" /></a></span>
+++ /dev/null
-description="For each link, add a ReadItYourself icon to save the shaared URL."
-parameters=READITYOUSELF_URL;
\ No newline at end of file
+++ /dev/null
-<?php
-
-/**
- * Plugin readityourself
- */
-
-// If we're talking about https://github.com/memiks/readityourself
-// it seems kinda dead.
-// Not tested.
-
-/**
- * Init function, return an error if the server is not set.
- *
- * @param $conf ConfigManager instance.
- *
- * @return array Eventual error.
- */
-function readityourself_init($conf)
-{
- $riyUrl = $conf->get('plugins.READITYOUSELF_URL');
- if (empty($riyUrl)) {
- $error = 'Readityourself plugin error: '.
- 'Please define the "READITYOUSELF_URL" setting in the plugin administration page.';
- return array($error);
- }
-}
-
-/**
- * Add readityourself icon to link_plugin when rendering linklist.
- *
- * @param mixed $data Linklist data.
- * @param ConfigManager $conf Configuration Manager instance.
- *
- * @return mixed - linklist data with readityourself plugin.
- */
-function hook_readityourself_render_linklist($data, $conf)
-{
- $riyUrl = $conf->get('plugins.READITYOUSELF_URL');
- if (empty($riyUrl)) {
- return $data;
- }
-
- $readityourself_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/readityourself/readityourself.html');
-
- foreach ($data['links'] as &$value) {
- $readityourself = sprintf($readityourself_html, $riyUrl, $value['url'], PluginManager::$PLUGINS_PATH);
- $value['link_plugin'][] = $readityourself;
- }
-
- return $data;
-}
--- /dev/null
+<?php
+
+require_once 'application/FileUtils.php';
+
+/**
+ * Class FileUtilsTest
+ *
+ * Test file utility class.
+ */
+class FileUtilsTest extends PHPUnit_Framework_TestCase
+{
+ /**
+ * @var string Test file path.
+ */
+ protected static $file = 'sandbox/flat.db';
+
+ /**
+ * Delete test file after every test.
+ */
+ public function tearDown()
+ {
+ @unlink(self::$file);
+ }
+
+ /**
+ * Test writeDB, then readDB with different data.
+ */
+ public function testSimpleWriteRead()
+ {
+ $data = ['blue', 'red'];
+ $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+ $this->assertTrue(startsWith(file_get_contents(self::$file), '<?php /*'));
+ $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+
+ $data = 0;
+ $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+ $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+
+ $data = null;
+ $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+ $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+
+ $data = false;
+ $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+ $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+ }
+
+ /**
+ * File not writable: raise an exception.
+ *
+ * @expectedException IOException
+ * @expectedExceptionMessage Error accessing "sandbox/flat.db"
+ */
+ public function testWriteWithoutPermission()
+ {
+ touch(self::$file);
+ chmod(self::$file, 0440);
+ FileUtils::writeFlatDB(self::$file, null);
+ }
+
+ /**
+ * Folder non existent: raise an exception.
+ *
+ * @expectedException IOException
+ * @expectedExceptionMessage Error accessing "nopefolder"
+ */
+ public function testWriteFolderDoesNotExist()
+ {
+ FileUtils::writeFlatDB('nopefolder/file', null);
+ }
+
+ /**
+ * Folder non writable: raise an exception.
+ *
+ * @expectedException IOException
+ * @expectedExceptionMessage Error accessing "sandbox"
+ */
+ public function testWriteFolderPermission()
+ {
+ chmod(dirname(self::$file), 0555);
+ try {
+ FileUtils::writeFlatDB(self::$file, null);
+ } catch (Exception $e) {
+ chmod(dirname(self::$file), 0755);
+ throw $e;
+ }
+ }
+
+ /**
+ * Read non existent file, use default parameter.
+ */
+ public function testReadNotExistentFile()
+ {
+ $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
+ $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
+ }
+
+ /**
+ * Read non readable file, use default parameter.
+ */
+ public function testReadNotReadable()
+ {
+ touch(self::$file);
+ chmod(self::$file, 0220);
+ $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
+ $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
+ }
+}
--- /dev/null
+<?php
+
+require_once 'application/History.php';
+
+
+class HistoryTest extends PHPUnit_Framework_TestCase
+{
+ /**
+ * @var string History file path
+ */
+ protected static $historyFilePath = 'sandbox/history.php';
+
+ /**
+ * Delete history file.
+ */
+ public function tearDown()
+ {
+ @unlink(self::$historyFilePath);
+ }
+
+ /**
+ * Test that the history file is created if it doesn't exist.
+ */
+ public function testConstructLazyLoading()
+ {
+ new History(self::$historyFilePath);
+ $this->assertFileNotExists(self::$historyFilePath);
+ }
+
+ /**
+ * Test that the history file is created if it doesn't exist.
+ */
+ public function testAddEventCreateFile()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->updateSettings();
+ $this->assertFileExists(self::$historyFilePath);
+ }
+
+ /**
+ * Not writable history file: raise an exception.
+ *
+ * @expectedException Exception
+ * @expectedExceptionMessage History file isn't readable or writable
+ */
+ public function testConstructNotWritable()
+ {
+ touch(self::$historyFilePath);
+ chmod(self::$historyFilePath, 0440);
+ $history = new History(self::$historyFilePath);
+ $history->updateSettings();
+ }
+
+ /**
+ * Not parsable history file: raise an exception.
+ *
+ * @expectedException Exception
+ * @expectedExceptionMessageRegExp /Could not parse history file/
+ */
+ public function testConstructNotParsable()
+ {
+ file_put_contents(self::$historyFilePath, 'not parsable');
+ $history = new History(self::$historyFilePath);
+ // gzinflate generates a warning
+ @$history->updateSettings();
+ }
+
+ /**
+ * Test add link event
+ */
+ public function testAddLink()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->addLink(['id' => 0]);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::CREATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(0, $actual['id']);
+
+ $history = new History(self::$historyFilePath);
+ $history->addLink(['id' => 1]);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::CREATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+
+ $history = new History(self::$historyFilePath);
+ $history->addLink(['id' => 'str']);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::CREATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals('str', $actual['id']);
+ }
+
+ /**
+ * Test updated link event
+ */
+ public function testUpdateLink()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->updateLink(['id' => 1]);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::UPDATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+ }
+
+ /**
+ * Test delete link event
+ */
+ public function testDeleteLink()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->deleteLink(['id' => 1]);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::DELETED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+ }
+
+ /**
+ * Test updated settings event
+ */
+ public function testUpdateSettings()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->updateSettings();
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::SETTINGS, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEmpty($actual['id']);
+ }
+
+ /**
+ * Make sure that new items are stored at the beginning
+ */
+ public function testHistoryOrder()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->updateLink(['id' => 1]);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::UPDATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+
+ $history->addLink(['id' => 1]);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::CREATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+ }
+
+ /**
+ * Re-read history from file after writing an event
+ */
+ public function testHistoryRead()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->updateLink(['id' => 1]);
+ $history = new History(self::$historyFilePath);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::UPDATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+ }
+
+ /**
+ * Re-read history from file after writing an event and make sure that the order is correct
+ */
+ public function testHistoryOrderRead()
+ {
+ $history = new History(self::$historyFilePath);
+ $history->updateLink(['id' => 1]);
+ $history->addLink(['id' => 1]);
+
+ $history = new History(self::$historyFilePath);
+ $actual = $history->getHistory()[0];
+ $this->assertEquals(History::CREATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+
+ $actual = $history->getHistory()[1];
+ $this->assertEquals(History::UPDATED, $actual['event']);
+ $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+ $this->assertEquals(1, $actual['id']);
+ }
+
+ /**
+ * Test retention time: delete old entries.
+ */
+ public function testHistoryRententionTime()
+ {
+ $history = new History(self::$historyFilePath, 5);
+ $history->updateLink(['id' => 1]);
+ $this->assertEquals(1, count($history->getHistory()));
+ $arr = $history->getHistory();
+ $arr[0]['datetime'] = (new DateTime('-1 hour'))->format(DateTime::ATOM);
+ FileUtils::writeFlatDB(self::$historyFilePath, $arr);
+
+ $history = new History(self::$historyFilePath, 60);
+ $this->assertEquals(1, count($history->getHistory()));
+ $this->assertEquals(1, $history->getHistory()[0]['id']);
+ $history->updateLink(['id' => 2]);
+ $this->assertEquals(1, count($history->getHistory()));
+ $this->assertEquals(2, $history->getHistory()[0]['id']);
+ }
+}
* Attempt to instantiate a LinkDB whereas the datastore is not writable
*
* @expectedException IOException
- * @expectedExceptionMessageRegExp /Error accessing\nnull/
+ * @expectedExceptionMessageRegExp /Error accessing "null"/
*/
public function testConstructDatastoreNotWriteable()
{
*/
protected static $testDatastore = 'sandbox/datastore.php';
+ /**
+ * @var string History file path
+ */
+ protected static $historyFilePath = 'sandbox/history.php';
+
/**
* @var LinkDB private LinkDB instance
*/
*/
protected $conf;
+ /**
+ * @var History instance.
+ */
+ protected $history;
+
/**
* @var string Save the current timezone.
*/
$this->linkDb = new LinkDB(self::$testDatastore, true, false);
$this->conf = new ConfigManager('tests/utils/config/configJson');
$this->conf->set('resource.page_cache', $this->pagecache);
+ $this->history = new History(self::$historyFilePath);
+ }
+
+ /**
+ * Delete history file.
+ */
+ public function tearDown()
+ {
+ @unlink(self::$historyFilePath);
}
public static function tearDownAfterClass()
$this->assertEquals(
'File empty.htm (0 bytes) has an unknown file format.'
.' Nothing was imported.',
- NetscapeBookmarkUtils::import(NULL, $files, NULL, $this->conf)
+ NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
);
$this->assertEquals(0, count($this->linkDb));
}
$files = file2array('no_doctype.htm');
$this->assertEquals(
'File no_doctype.htm (350 bytes) has an unknown file format. Nothing was imported.',
- NetscapeBookmarkUtils::import(NULL, $files, NULL, $this->conf)
+ NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
);
$this->assertEquals(0, count($this->linkDb));
}
$this->assertEquals(
'File internet_explorer_encoding.htm (356 bytes) was successfully processed:'
.' 1 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(1, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_nested.htm (1337 bytes) was successfully processed:'
.' 8 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(8, count($this->linkDb));
$this->assertEquals(2, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(1, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(2, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(2, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 2 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 2 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(2, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 0 links imported, 0 links overwritten, 2 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File netscape_basic.htm (482 bytes) was successfully processed:'
.' 2 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(2, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->assertEquals(
'File same_date.htm (453 bytes) was successfully processed:'
.' 3 links imported, 0 links overwritten, 0 links skipped.',
- NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+ NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf, $this->history)
);
$this->assertEquals(3, count($this->linkDb));
$this->assertEquals(0, count_private($this->linkDb));
$this->linkDb[2]['id']
);
}
+
+ public function testImportCreateUpdateHistory()
+ {
+ $post = [
+ 'privacy' => 'public',
+ 'overwrite' => 'true',
+ ];
+ $files = file2array('netscape_basic.htm');
+ $nbLinks = 2;
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history);
+ $history = $this->history->getHistory();
+ $this->assertEquals($nbLinks, count($history));
+ foreach ($history as $value) {
+ $this->assertEquals(History::CREATED, $value['event']);
+ $this->assertTrue(new DateTime('-5 seconds') < DateTime::createFromFormat(DateTime::ATOM, $value['datetime']));
+ $this->assertTrue(is_int($value['id']));
+ }
+
+ // re-import as private, enable overwriting
+ NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history);
+ $history = $this->history->getHistory();
+ $this->assertEquals($nbLinks * 2, count($history));
+ for ($i = 0 ; $i < $nbLinks ; $i++) {
+ $this->assertEquals(History::UPDATED, $history[$i]['event']);
+ $this->assertTrue(new DateTime('-5 seconds') < DateTime::createFromFormat(DateTime::ATOM, $history[$i]['datetime']));
+ $this->assertTrue(is_int($history[$i]['id']));
+ }
+ }
}
*/
class TimeZoneTest extends PHPUnit_Framework_TestCase
{
+ /**
+ * @var array of timezones
+ */
+ protected $installedTimezones;
+
+ public function setUp()
+ {
+ $this->installedTimezones = [
+ 'Antarctica/Syowa',
+ 'Europe/London',
+ 'Europe/Paris',
+ 'UTC'
+ ];
+ }
+
/**
* Generate a timezone selection form
*/
public function testGenerateTimeZoneForm()
{
- $generated = generateTimeZoneForm();
+ $expected = [
+ 'continents' => [
+ 'Antarctica',
+ 'Europe',
+ 'UTC',
+ 'selected' => '',
+ ],
+ 'cities' => [
+ ['continent' => 'Antarctica', 'city' => 'Syowa'],
+ ['continent' => 'Europe', 'city' => 'London'],
+ ['continent' => 'Europe', 'city' => 'Paris'],
+ ['continent' => 'UTC', 'city' => 'UTC'],
+ 'selected' => '',
+ ]
+ ];
- // HTML form
- $this->assertStringStartsWith('Continent:<select', $generated[0]);
- $this->assertContains('selected="selected"', $generated[0]);
- $this->assertStringEndsWith('</select><br />', $generated[0]);
+ list($continents, $cities) = generateTimeZoneData($this->installedTimezones);
- // Javascript handler
- $this->assertStringStartsWith('<script>', $generated[1]);
- $this->assertContains(
- '<option value=\"Bermuda\">Bermuda<\/option>',
- $generated[1]
- );
- $this->assertStringEndsWith('</script>', $generated[1]);
+ $this->assertEquals($expected['continents'], $continents);
+ $this->assertEquals($expected['cities'], $cities);
}
/**
*/
public function testGenerateTimeZoneFormPreselected()
{
- $generated = generateTimeZoneForm('Antarctica/Syowa');
-
- // HTML form
- $this->assertStringStartsWith('Continent:<select', $generated[0]);
- $this->assertContains(
- 'value="Antarctica" selected="selected"',
- $generated[0]
- );
- $this->assertContains(
- 'value="Syowa" selected="selected"',
- $generated[0]
- );
- $this->assertStringEndsWith('</select><br />', $generated[0]);
+ $expected = [
+ 'continents' => [
+ 'Antarctica',
+ 'Europe',
+ 'UTC',
+ 'selected' => 'Antarctica',
+ ],
+ 'cities' => [
+ ['continent' => 'Antarctica', 'city' => 'Syowa'],
+ ['continent' => 'Europe', 'city' => 'London'],
+ ['continent' => 'Europe', 'city' => 'Paris'],
+ ['continent' => 'UTC', 'city' => 'UTC'],
+ 'selected' => 'Syowa',
+ ]
+ ];
+ list($continents, $cities) = generateTimeZoneData($this->installedTimezones, 'Antarctica/Syowa');
- // Javascript handler
- $this->assertStringStartsWith('<script>', $generated[1]);
- $this->assertContains(
- '<option value=\"Bermuda\">Bermuda<\/option>',
- $generated[1]
- );
- $this->assertStringEndsWith('</script>', $generated[1]);
+ $this->assertEquals($expected['continents'], $continents);
+ $this->assertEquals($expected['cities'], $cities);
}
/**
*/
require_once 'application/Utils.php';
+require_once 'application/Languages.php';
require_once 'tests/utils/ReferenceSessionIdHashes.php';
// Initialize reference data before PHPUnit starts a session
$this->assertFalse(format_date([]));
$this->assertFalse(format_date(null));
}
+
+ /**
+ * Test is_integer_mixed with valid values
+ */
+ public function testIsIntegerMixedValid()
+ {
+ $this->assertTrue(is_integer_mixed(12));
+ $this->assertTrue(is_integer_mixed('12'));
+ $this->assertTrue(is_integer_mixed(-12));
+ $this->assertTrue(is_integer_mixed('-12'));
+ $this->assertTrue(is_integer_mixed(0));
+ $this->assertTrue(is_integer_mixed('0'));
+ $this->assertTrue(is_integer_mixed(0x0a));
+ }
+
+ /**
+ * Test is_integer_mixed with invalid values
+ */
+ public function testIsIntegerMixedInvalid()
+ {
+ $this->assertFalse(is_integer_mixed(true));
+ $this->assertFalse(is_integer_mixed(false));
+ $this->assertFalse(is_integer_mixed([]));
+ $this->assertFalse(is_integer_mixed(['test']));
+ $this->assertFalse(is_integer_mixed([12]));
+ $this->assertFalse(is_integer_mixed(new DateTime()));
+ $this->assertFalse(is_integer_mixed('0x0a'));
+ $this->assertFalse(is_integer_mixed('12k'));
+ $this->assertFalse(is_integer_mixed('k12'));
+ $this->assertFalse(is_integer_mixed(''));
+ }
+
+ /**
+ * Test return_bytes
+ */
+ public function testReturnBytes()
+ {
+ $this->assertEquals(2 * 1024, return_bytes('2k'));
+ $this->assertEquals(2 * 1024, return_bytes('2K'));
+ $this->assertEquals(2 * (pow(1024, 2)), return_bytes('2m'));
+ $this->assertEquals(2 * (pow(1024, 2)), return_bytes('2M'));
+ $this->assertEquals(2 * (pow(1024, 3)), return_bytes('2g'));
+ $this->assertEquals(2 * (pow(1024, 3)), return_bytes('2G'));
+ $this->assertEquals(374, return_bytes('374'));
+ $this->assertEquals(374, return_bytes(374));
+ $this->assertEquals(0, return_bytes('0'));
+ $this->assertEquals(0, return_bytes(0));
+ $this->assertEquals(-1, return_bytes('-1'));
+ $this->assertEquals(-1, return_bytes(-1));
+ $this->assertEquals('', return_bytes(''));
+ }
+
+ /**
+ * Test human_bytes
+ */
+ public function testHumanBytes()
+ {
+ $this->assertEquals('2kiB', human_bytes(2 * 1024));
+ $this->assertEquals('2kiB', human_bytes(strval(2 * 1024)));
+ $this->assertEquals('2MiB', human_bytes(2 * (pow(1024, 2))));
+ $this->assertEquals('2MiB', human_bytes(strval(2 * (pow(1024, 2)))));
+ $this->assertEquals('2GiB', human_bytes(2 * (pow(1024, 3))));
+ $this->assertEquals('2GiB', human_bytes(strval(2 * (pow(1024, 3)))));
+ $this->assertEquals('374B', human_bytes(374));
+ $this->assertEquals('374B', human_bytes('374'));
+ $this->assertEquals('232kiB', human_bytes(237481));
+ $this->assertEquals('Unlimited', human_bytes('0'));
+ $this->assertEquals('Unlimited', human_bytes(0));
+ $this->assertEquals('Setting not set', human_bytes(''));
+ }
+
+ /**
+ * Test get_max_upload_size with formatting
+ */
+ public function testGetMaxUploadSize()
+ {
+ $this->assertEquals('1MiB', get_max_upload_size(2097152, '1024k'));
+ $this->assertEquals('1MiB', get_max_upload_size('1m', '2m'));
+ $this->assertEquals('100B', get_max_upload_size(100, 100));
+ }
+
+ /**
+ * Test get_max_upload_size without formatting
+ */
+ public function testGetMaxUploadSizeRaw()
+ {
+ $this->assertEquals('1048576', get_max_upload_size(2097152, '1024k', false));
+ $this->assertEquals('1048576', get_max_upload_size('1m', '2m', false));
+ $this->assertEquals('100', get_max_upload_size(100, 100, false));
+ }
}
+++ /dev/null
-<?php
-use Shaarli\Config\ConfigManager;
-
-/**
- * PluginReadityourselfTest.php.php
- */
-
-require_once 'plugins/readityourself/readityourself.php';
-
-/**
- * Class PluginWallabagTest
- * Unit test for the Wallabag plugin
- */
-class PluginReadityourselfTest extends PHPUnit_Framework_TestCase
-{
- /**
- * Reset plugin path
- */
- public function setUp()
- {
- PluginManager::$PLUGINS_PATH = 'plugins';
- }
-
- /**
- * Test Readityourself init without errors.
- */
- public function testReadityourselfInitNoError()
- {
- $conf = new ConfigManager('');
- $conf->set('plugins.READITYOUSELF_URL', 'value');
- $errors = readityourself_init($conf);
- $this->assertEmpty($errors);
- }
-
- /**
- * Test Readityourself init with errors.
- */
- public function testReadityourselfInitError()
- {
- $conf = new ConfigManager('');
- $errors = readityourself_init($conf);
- $this->assertNotEmpty($errors);
- }
-
- /**
- * Test render_linklist hook.
- */
- public function testReadityourselfLinklist()
- {
- $conf = new ConfigManager('');
- $conf->set('plugins.READITYOUSELF_URL', 'value');
- $str = 'http://randomstr.com/test';
- $data = array(
- 'title' => $str,
- 'links' => array(
- array(
- 'url' => $str,
- )
- )
- );
-
- $data = hook_readityourself_render_linklist($data, $conf);
- $link = $data['links'][0];
- // data shouldn't be altered
- $this->assertEquals($str, $data['title']);
- $this->assertEquals($str, $link['url']);
-
- // plugin data
- $this->assertEquals(1, count($link['link_plugin']));
- $this->assertNotFalse(strpos($link['link_plugin'][0], $str));
- }
-
- /**
- * Test without config: nothing should happened.
- */
- public function testReadityourselfLinklistWithoutConfig()
- {
- $conf = new ConfigManager('');
- $conf->set('plugins.READITYOUSELF_URL', null);
- $str = 'http://randomstr.com/test';
- $data = array(
- 'title' => $str,
- 'links' => array(
- array(
- 'url' => $str,
- )
- )
- );
-
- $data = hook_readityourself_render_linklist($data, $conf);
- $link = $data['links'][0];
- // data shouldn't be altered
- $this->assertEquals($str, $data['title']);
- $this->assertEquals($str, $link['url']);
-
- // plugin data
- $this->assertArrayNotHasKey('link_plugin', $link);
- }
-}
<div class="pure-u-lg-{$ratioLabel} pure-u-1">
<div class="form-label">
<label for="titleLink">
- <span class="label-name">{'Title link'|t}</span><br>
+ <span class="label-name">{'Home link'|t}</span><br>
<span class="label-desc">{'Default value'|t}: ?</span>
</label>
</div>
<div class="pure-u-lg-{$ratioLabel} pure-u-1 ">
<div class="form-label">
<label>
- <span class="label-name">{'Timezone'|t}</span>
+ <span class="label-name">{'Timezone'|t}</span><br>
+ <span class="label-desc">{'Continent'|t} · {'City'|t}</span>
</label>
</div>
</div>
<div class="pure-u-lg-{$ratioInput} pure-u-1 ">
<div class="form-input">
- {ignore}FIXME! too hackish, needs to be fixed upstream{/ignore}
- <div class="timezone" id="timezone-remove">{$timezone_form}</div>
- <div class="timezone" id="timezone-add"></div>
+ <div class="timezone">
+ <select id="continent" name="continent">
+ {loop="$continents"}
+ {if="$key !== 'selected'"}
+ <option value="{$value}" {if="$continents.selected === $value"}selected{/if}>
+ {$value}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ <select id="city" name="city">
+ {loop="$cities"}
+ {if="$key !== 'selected'"}
+ <option value="{$value.city}"
+ {if="$cities.selected === $value.city"}selected{/if}
+ data-continent="{$value.continent}">
+ {$value.city}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ </div>
</div>
</div>
</div>
<div class="center" id="import-field">
<input type="hidden" name="MAX_FILE_SIZE" value="{$maxfilesize}">
<input type="file" name="filetoupload">
+ <p><br>Maximum size allowed: <strong>{$maxfilesizeHuman}</strong></p>
</div>
<div class="pure-g">
</div>
<div class="pure-u-lg-{$ratioInput} pure-u-1">
<div class="form-input">
- <input type="text" name="setpassword" id="password">
+ <input type="password" name="setpassword" id="password">
</div>
</div>
</div>
<div class="pure-g">
- <div class="pure-u-lg-{$ratioLabel} pure-u-1 ">
+ <div class="pure-u-lg-{$ratioLabel} pure-u-1">
<div class="form-label">
- <label>
- <span class="label-name">{'Timezone'|t}</span>
+ <label for="title">
+ <span class="label-name">{'Shaarli title'|t}</span>
</label>
</div>
</div>
- <div class="pure-u-lg-{$ratioInput} pure-u-1 ">
+ <div class="pure-u-lg-{$ratioInput} pure-u-1">
<div class="form-input">
- {ignore}FIXME! too hackish, needs to be fixed upstream{/ignore}
- <div class="timezone" id="timezone-remove">{$timezone_html}</div>
- <div class="timezone" id="timezone-add"></div>
+ <input type="text" name="title" id="title" placeholder="{'My links'|t}">
</div>
</div>
</div>
<div class="pure-g">
<div class="pure-u-lg-{$ratioLabel} pure-u-1">
<div class="form-label">
- <label for="title">
- <span class="label-name">{'Shaarli title'|t}</span>
+ <label>
+ <span class="label-name">{'Timezone'|t}</span><br>
+ <span class="label-desc">{'Continent'|t} · {'City'|t}</span>
</label>
</div>
</div>
<div class="pure-u-lg-{$ratioInput} pure-u-1">
<div class="form-input">
- <input type="text" name="title" id="title" placeholder="{'My links'|t}">
+ <div class="timezone">
+ <select id="continent" name="continent">
+ {loop="$continents"}
+ {if="$key !== 'selected'"}
+ <option value="{$value}" {if="$continents.selected === $value"}selected{/if}>
+ {$value}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ <select id="city" name="city">
+ {loop="$cities"}
+ {if="$key !== 'selected'"}
+ <option value="{$value.city}"
+ {if="$cities.selected === $value.city"}selected{/if}
+ data-continent="{$value.continent}">
+ {$value.city}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ </div>
</div>
</div>
</div>
}
}
- document.getElementById('menu-toggle').addEventListener('click', function (e) {
- toggleMenu();
- });
+ var menuToggle = document.getElementById('menu-toggle');
+ if (menuToggle != null) {
+ menuToggle.addEventListener('click', function (e) {
+ toggleMenu();
+ });
+ }
window.addEventListener(WINDOW_CHANGE_EVENT, closeMenu);
})(this, this.document);
});
}
- /**
- * TimeZome select
- * FIXME! way too hackish
- */
- var toRemove = document.getElementById('timezone-remove');
- if (toRemove != null) {
- var firstSelect = toRemove.getElementsByTagName('select')[0];
- var secondSelect = toRemove.getElementsByTagName('select')[1];
- toRemove.parentNode.removeChild(toRemove);
- var toAdd = document.getElementById('timezone-add');
- var newTimezone = '<span class="timezone-continent">Continent ' + firstSelect.outerHTML + '</span>';
- newTimezone += ' <span class="timezone-country">Country ' + secondSelect.outerHTML + '</span>';
- toAdd.innerHTML = newTimezone;
- }
-
/**
* Awesomplete trigger.
*/
}
});
});
+
+ var continent = document.getElementById('continent');
+ var city = document.getElementById('city');
+ if (continent != null && city != null) {
+ continent.addEventListener('change', function(event) {
+ hideTimezoneCities(city, continent.options[continent.selectedIndex].value, true);
+ });
+ hideTimezoneCities(city, continent.options[continent.selectedIndex].value, false);
+ }
};
function activateFirefoxSocial(node) {
var activate = new CustomEvent("ActivateSocialFeature");
node.dispatchEvent(activate);
}
+
+/**
+ * Add the class 'hidden' to city options not attached to the current selected continent.
+ *
+ * @param cities List of <option> elements
+ * @param currentContinent Current selected continent
+ * @param reset Set to true to reset the selected value
+ */
+function hideTimezoneCities(cities, currentContinent, reset = false) {
+ var first = true;
+ [].forEach.call(cities, function(option) {
+ if (option.getAttribute('data-continent') != currentContinent) {
+ option.className = 'hidden';
+ } else {
+ option.className = '';
+ if (reset === true && first === true) {
+ option.setAttribute('selected', 'selected');
+ first = false;
+ }
+ }
+ });
+}
<body onload="document.configform.title.focus();">
<div id="pageheader">
{include="page.header"}
- {$timezone_js}
<form method="POST" action="#" name="configform" id="configform">
<input type="hidden" name="token" value="{$token}">
<table id="configuration_table">
</tr>
<tr>
- <td><b>Title link:</b></td>
+ <td><b>Home link:</b></td>
<td><input type="text" name="titleLink" id="titleLink" size="50" value="{$titleLink}"><br/><label
for="titleLink">(default value is: ?)</label></td>
</tr>
<tr>
<td><b>Timezone:</b></td>
- <td>{$timezone_form}</td>
+ <td>
+ <select id="continent" name="continent">
+ {loop="$continents"}
+ {if="$key !== 'selected'"}
+ <option value="{$value}" {if="$continents.selected === $value"}selected{/if}>
+ {$value}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ <select id="city" name="city">
+ {loop="$cities"}
+ {if="$key !== 'selected'"}
+ <option value="{$value.city}"
+ {if="$cities.selected === $value.city"}selected{/if}
+ data-continent="{$value.continent}">
+ {$value.city}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ </td>
</tr>
<tr>
font-weight: bold;
}
+.hidden {
+ display: none;
+}
+
/* Buttons */
.bigbutton, #pageheader a.bigbutton {
background-color: #c0c0c0;
<div id="pageheader">
{include="page.header"}
<div id="uploaddiv">
- Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...) (Max: {$maxfilesize} bytes).
+ Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...) (Max: {$maxfilesize}).
<form method="POST" action="?do=import" enctype="multipart/form-data"
name="uploadform" id="uploadform">
<input type="hidden" name="token" value="{$token}">
<!DOCTYPE html>
<html>
-<head>{include="includes"}{$timezone_js}</head>
+<head>{include="includes"}</head>
<body onload="document.installform.setlogin.focus();">
<div id="install">
<h1>Shaarli</h1>
<table>
<tr><td><b>Login:</b></td><td><input type="text" name="setlogin" size="30"></td></tr>
<tr><td><b>Password:</b></td><td><input type="password" name="setpassword" size="30"></td></tr>
- {$timezone_html}
+ <tr>
+ <td><b>Timezone:</b></td>
+ <td>
+ <select id="continent" name="continent">
+ {loop="$continents"}
+ {if="$key !== 'selected'"}
+ <option value="{$value}" {if="$continents.selected === $value"}selected{/if}>
+ {$value}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ <select id="city" name="city">
+ {loop="$cities"}
+ {if="$key !== 'selected'"}
+ <option value="{$value.city}"
+ {if="$cities.selected === $value.city"}selected{/if}
+ data-continent="{$value.continent}">
+ {$value.city}
+ </option>
+ {/if}
+ {/loop}
+ </select>
+ </td>
+ </tr>
<tr><td><b>Page title:</b></td><td><input type="text" name="title" size="30"></td></tr>
<tr><td valign="top"><b>Update:</b></td><td>
<input type="checkbox" name="updateCheck" id="updateCheck" checked="checked"><label for="updateCheck"> Notify me when a new release is ready</label></td>
--- /dev/null
+window.onload = function () {
+ var continent = document.getElementById('continent');
+ var city = document.getElementById('city');
+ if (continent != null && city != null) {
+ continent.addEventListener('change', function(event) {
+ hideTimezoneCities(city, continent.options[continent.selectedIndex].value, true);
+ });
+ hideTimezoneCities(city, continent.options[continent.selectedIndex].value, false);
+ }
+};
+
+/**
+ * Add the class 'hidden' to city options not attached to the current selected continent.
+ *
+ * @param cities List of <option> elements
+ * @param currentContinent Current selected continent
+ * @param reset Set to true to reset the selected value
+ */
+function hideTimezoneCities(cities, currentContinent, reset = false) {
+ var first = true;
+ [].forEach.call(cities, function(option) {
+ if (option.getAttribute('data-continent') != currentContinent) {
+ option.className = 'hidden';
+ } else {
+ option.className = '';
+ if (reset === true && first === true) {
+ option.setAttribute('selected', 'selected');
+ first = false;
+ }
+ }
+ });
+}
<script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script>
{/if}
+<script src="js/shaarli.js"></script>
{loop="$plugins_footer.js_files"}
<script src="{$value}#"></script>
{/loop}
-<div id="logo" title="Share your links !" onclick="document.location='?';"></div>
+<div id="logo" title="Share your links !" onclick="document.location='{$titleLink}';"></div>
<div id="linkcount" class="nomobile">
{if="!empty($linkcount)"}{$linkcount} links{/if}<br>
{if="!empty($_GET['source']) && $_GET['source']=='bookmarklet'"}
{ignore} When called as a popup from bookmarklet, do not display menu. {/ignore}
{else}
-<li><a href="?" class="nomobile">Home</a></li>
+<li><a href="{$titleLink}" class="nomobile">Home</a></li>
{if="isLoggedIn()"}
<li><a href="?do=logout">Logout</a></li>
<li><a href="?do=tools">Tools</a></li>