From bdc5152d486ca75372c271f94623b248bc127800 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sun, 2 Dec 2018 23:24:58 +0100 Subject: namespacing: \Shaarli\History Signed-off-by: VirtualTam --- application/History.php | 13 +++++++++---- application/NetscapeBookmarkUtils.php | 1 + application/api/controllers/ApiController.php | 2 +- index.php | 1 + tests/HistoryTest.php | 7 +++++-- tests/NetscapeBookmarkUtils/BookmarkImportTest.php | 1 + tests/api/controllers/history/HistoryTest.php | 20 ++++++++++---------- tests/api/controllers/links/DeleteLinkTest.php | 6 +++--- tests/api/controllers/links/PostLinkTest.php | 8 ++++---- tests/api/controllers/links/PutLinkTest.php | 8 ++++---- tests/api/controllers/tags/DeleteTagTest.php | 10 +++++----- tests/api/controllers/tags/PutTagTest.php | 8 ++++---- tests/utils/ReferenceHistory.php | 2 ++ 13 files changed, 50 insertions(+), 37 deletions(-) diff --git a/application/History.php b/application/History.php index 35ec016a..8074a017 100644 --- a/application/History.php +++ b/application/History.php @@ -1,4 +1,9 @@ historyFilePath)) { + if (!is_file($this->historyFilePath)) { FileUtils::writeFlatDB($this->historyFilePath, []); } - if (! is_writable($this->historyFilePath)) { + if (!is_writable($this->historyFilePath)) { throw new Exception(t('History file isn\'t readable or writable')); } } @@ -191,7 +196,7 @@ class History */ protected function write() { - $comparaison = new DateTime('-'. $this->retentionTime . ' seconds'); + $comparaison = new DateTime('-' . $this->retentionTime . ' seconds'); foreach ($this->history as $key => $value) { if ($value['datetime'] < $comparaison) { unset($this->history[$key]); diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php index 84dd2b20..c0c007ea 100644 --- a/application/NetscapeBookmarkUtils.php +++ b/application/NetscapeBookmarkUtils.php @@ -2,6 +2,7 @@ use Psr\Log\LogLevel; use Shaarli\Config\ConfigManager; +use Shaarli\History; use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; use Katzgrau\KLogger\Logger; diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index 9edefcf6..47e0e178 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php @@ -30,7 +30,7 @@ abstract class ApiController protected $linkDb; /** - * @var \History + * @var \Shaarli\History */ protected $history; diff --git a/index.php b/index.php index acfcc660..cc41d80c 100644 --- a/index.php +++ b/index.php @@ -76,6 +76,7 @@ require_once 'application/PluginManager.php'; require_once 'application/Router.php'; require_once 'application/Updater.php'; use \Shaarli\Config\ConfigManager; +use Shaarli\History; use \Shaarli\Languages; use \Shaarli\Security\LoginManager; use \Shaarli\Security\SessionManager; diff --git a/tests/HistoryTest.php b/tests/HistoryTest.php index d3bef5a3..7723c461 100644 --- a/tests/HistoryTest.php +++ b/tests/HistoryTest.php @@ -1,9 +1,12 @@ container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = true; - $this->container['history'] = new \History(self::$testHistory); + $this->container['history'] = new \Shaarli\History(self::$testHistory); $this->controller = new History($this->container); } @@ -78,35 +78,35 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals($this->refHistory->count(), count($data)); - $this->assertEquals(\History::DELETED, $data[0]['event']); + $this->assertEquals(\Shaarli\History::DELETED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170303_121216')->format(\DateTime::ATOM), $data[0]['datetime'] ); $this->assertEquals(124, $data[0]['id']); - $this->assertEquals(\History::SETTINGS, $data[1]['event']); + $this->assertEquals(\Shaarli\History::SETTINGS, $data[1]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170302_121215')->format(\DateTime::ATOM), $data[1]['datetime'] ); $this->assertNull($data[1]['id']); - $this->assertEquals(\History::UPDATED, $data[2]['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $data[2]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170301_121214')->format(\DateTime::ATOM), $data[2]['datetime'] ); $this->assertEquals(123, $data[2]['id']); - $this->assertEquals(\History::CREATED, $data[3]['event']); + $this->assertEquals(\Shaarli\History::CREATED, $data[3]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170201_121214')->format(\DateTime::ATOM), $data[3]['datetime'] ); $this->assertEquals(124, $data[3]['id']); - $this->assertEquals(\History::CREATED, $data[4]['event']); + $this->assertEquals(\Shaarli\History::CREATED, $data[4]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170101_121212')->format(\DateTime::ATOM), $data[4]['datetime'] @@ -131,7 +131,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\History::DELETED, $data[0]['event']); + $this->assertEquals(\Shaarli\History::DELETED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170303_121216')->format(\DateTime::ATOM), $data[0]['datetime'] @@ -156,7 +156,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\History::CREATED, $data[0]['event']); + $this->assertEquals(\Shaarli\History::CREATED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170101_121212')->format(\DateTime::ATOM), $data[0]['datetime'] @@ -181,7 +181,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\History::DELETED, $data[0]['event']); + $this->assertEquals(\Shaarli\History::DELETED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170303_121216')->format(\DateTime::ATOM), $data[0]['datetime'] @@ -206,7 +206,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\History::SETTINGS, $data[0]['event']); + $this->assertEquals(\Shaarli\History::SETTINGS, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170302_121215')->format(\DateTime::ATOM), $data[0]['datetime'] diff --git a/tests/api/controllers/links/DeleteLinkTest.php b/tests/api/controllers/links/DeleteLinkTest.php index 7d797137..07371e7a 100644 --- a/tests/api/controllers/links/DeleteLinkTest.php +++ b/tests/api/controllers/links/DeleteLinkTest.php @@ -37,7 +37,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase protected $linkDB; /** - * @var \History instance. + * @var \Shaarli\History instance. */ protected $history; @@ -62,7 +62,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase $this->linkDB = new \LinkDB(self::$testDatastore, true, false); $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \History(self::$testHistory); + $this->history = new \Shaarli\History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = $this->linkDB; @@ -100,7 +100,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase $this->assertFalse(isset($this->linkDB[$id])); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\History::DELETED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::DELETED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/links/PostLinkTest.php b/tests/api/controllers/links/PostLinkTest.php index 5c2b5623..a73f443c 100644 --- a/tests/api/controllers/links/PostLinkTest.php +++ b/tests/api/controllers/links/PostLinkTest.php @@ -40,7 +40,7 @@ class PostLinkTest extends TestCase protected $refDB = null; /** - * @var \History instance. + * @var \Shaarli\History instance. */ protected $history; @@ -70,12 +70,12 @@ class PostLinkTest extends TestCase $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \History(self::$testHistory); + $this->history = new \Shaarli\History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); - $this->container['history'] = new \History(self::$testHistory); + $this->container['history'] = new \Shaarli\History(self::$testHistory); $this->controller = new Links($this->container); @@ -133,7 +133,7 @@ class PostLinkTest extends TestCase $this->assertEquals('', $data['updated']); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\History::CREATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::CREATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/links/PutLinkTest.php b/tests/api/controllers/links/PutLinkTest.php index f276b4c1..3bb4d43f 100644 --- a/tests/api/controllers/links/PutLinkTest.php +++ b/tests/api/controllers/links/PutLinkTest.php @@ -32,7 +32,7 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \History instance. + * @var \Shaarli\History instance. */ protected $history; @@ -62,12 +62,12 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \History(self::$testHistory); + $this->history = new \Shaarli\History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); - $this->container['history'] = new \History(self::$testHistory); + $this->container['history'] = new \Shaarli\History(self::$testHistory); $this->controller = new Links($this->container); @@ -119,7 +119,7 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase ); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\History::UPDATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/tags/DeleteTagTest.php b/tests/api/controllers/tags/DeleteTagTest.php index e0787ce2..a1e419cd 100644 --- a/tests/api/controllers/tags/DeleteTagTest.php +++ b/tests/api/controllers/tags/DeleteTagTest.php @@ -37,7 +37,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase protected $linkDB; /** - * @var \History instance. + * @var \Shaarli\History instance. */ protected $history; @@ -62,7 +62,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->linkDB = new \LinkDB(self::$testDatastore, true, false); $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \History(self::$testHistory); + $this->history = new \Shaarli\History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = $this->linkDB; @@ -103,12 +103,12 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase // 2 links affected $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\History::UPDATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); $historyEntry = $this->history->getHistory()[1]; - $this->assertEquals(\History::UPDATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); @@ -137,7 +137,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->assertTrue($tags[strtolower($tagName)] > 0); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\History::UPDATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/tags/PutTagTest.php b/tests/api/controllers/tags/PutTagTest.php index 38017243..c45fa722 100644 --- a/tests/api/controllers/tags/PutTagTest.php +++ b/tests/api/controllers/tags/PutTagTest.php @@ -33,7 +33,7 @@ class PutTagTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \History instance. + * @var \Shaarli\History instance. */ protected $history; @@ -68,7 +68,7 @@ class PutTagTest extends \PHPUnit_Framework_TestCase $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \History(self::$testHistory); + $this->history = new \Shaarli\History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; @@ -113,12 +113,12 @@ class PutTagTest extends \PHPUnit_Framework_TestCase $this->assertEquals(2, $tags[$newName]); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\History::UPDATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); $historyEntry = $this->history->getHistory()[1]; - $this->assertEquals(\History::UPDATED, $historyEntry['event']); + $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/utils/ReferenceHistory.php b/tests/utils/ReferenceHistory.php index 75cbb326..f19cdf2f 100644 --- a/tests/utils/ReferenceHistory.php +++ b/tests/utils/ReferenceHistory.php @@ -1,5 +1,7 @@ Date: Sun, 2 Dec 2018 23:31:40 +0100 Subject: namespacing: \Shaarli\Exceptions\IOException Signed-off-by: VirtualTam --- application/FileUtils.php | 2 ++ application/LinkDB.php | 3 +++ application/Updater.php | 1 + application/config/ConfigJson.php | 2 +- application/config/ConfigManager.php | 2 +- application/config/ConfigPhp.php | 2 +- application/exceptions/IOException.php | 5 ++++- composer.json | 1 + tests/FileUtilsTest.php | 8 +++++--- tests/LinkDBTest.php | 4 +++- tests/config/ConfigJsonTest.php | 4 ++-- 11 files changed, 24 insertions(+), 10 deletions(-) diff --git a/application/FileUtils.php b/application/FileUtils.php index b89ea12b..ba409821 100644 --- a/application/FileUtils.php +++ b/application/FileUtils.php @@ -1,5 +1,7 @@ path = $path; $this->message = empty($message) ? t('Error accessing') : $message; - $this->message .= ' "' . $this->path .'"'; + $this->message .= ' "' . $this->path . '"'; } } diff --git a/composer.json b/composer.json index dccf83b6..027203f4 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "Shaarli\\Api\\Exceptions\\": "application/api/exceptions", "Shaarli\\Config\\": "application/config/", "Shaarli\\Config\\Exception\\": "application/config/exception", + "Shaarli\\Exceptions\\": "application/exceptions", "Shaarli\\Security\\": "application/security" } } diff --git a/tests/FileUtilsTest.php b/tests/FileUtilsTest.php index d764e495..9596dba9 100644 --- a/tests/FileUtilsTest.php +++ b/tests/FileUtilsTest.php @@ -1,5 +1,7 @@ Date: Mon, 3 Dec 2018 00:08:04 +0100 Subject: namespacing: \Shaarli\Feed\{Cache,CachedPage,FeedBuilder} Signed-off-by: VirtualTam --- application/Cache.php | 38 ----- application/CachedPage.php | 59 ------- application/FeedBuilder.php | 296 --------------------------------- application/feed/Cache.php | 38 +++++ application/feed/CachedPage.php | 61 +++++++ application/feed/FeedBuilder.php | 299 ++++++++++++++++++++++++++++++++++ composer.json | 1 + index.php | 8 +- plugins/pubsubhubbub/pubsubhubbub.php | 1 + tests/CacheTest.php | 91 ----------- tests/CachedPageTest.php | 121 -------------- tests/FeedBuilderTest.php | 245 ---------------------------- tests/LinkDBTest.php | 4 +- tests/feed/CacheTest.php | 92 +++++++++++ tests/feed/CachedPageTest.php | 120 ++++++++++++++ tests/feed/FeedBuilderTest.php | 250 ++++++++++++++++++++++++++++ 16 files changed, 867 insertions(+), 857 deletions(-) delete mode 100644 application/Cache.php delete mode 100644 application/CachedPage.php delete mode 100644 application/FeedBuilder.php create mode 100644 application/feed/Cache.php create mode 100644 application/feed/CachedPage.php create mode 100644 application/feed/FeedBuilder.php delete mode 100644 tests/CacheTest.php delete mode 100644 tests/CachedPageTest.php delete mode 100644 tests/FeedBuilderTest.php create mode 100644 tests/feed/CacheTest.php create mode 100644 tests/feed/CachedPageTest.php create mode 100644 tests/feed/FeedBuilderTest.php diff --git a/application/Cache.php b/application/Cache.php deleted file mode 100644 index e5d43e61..00000000 --- a/application/Cache.php +++ /dev/null @@ -1,38 +0,0 @@ -cacheDir = $cacheDir; - $this->filename = $this->cacheDir.'/'.sha1($url).'.cache'; - $this->shouldBeCached = $shouldBeCached; - } - - /** - * Returns the cached version of a page, if it exists and should be cached - * - * @return string a cached version of the page if it exists, null otherwise - */ - public function cachedVersion() - { - if (!$this->shouldBeCached) { - return null; - } - if (is_file($this->filename)) { - return file_get_contents($this->filename); - } - return null; - } - - /** - * Puts a page in the cache - * - * @param string $pageContent XML content to cache - */ - public function cache($pageContent) - { - if (!$this->shouldBeCached) { - return; - } - file_put_contents($this->filename, $pageContent); - } -} diff --git a/application/FeedBuilder.php b/application/FeedBuilder.php deleted file mode 100644 index 73fafcbe..00000000 --- a/application/FeedBuilder.php +++ /dev/null @@ -1,296 +0,0 @@ -linkDB = $linkDB; - $this->feedType = $feedType; - $this->serverInfo = $serverInfo; - $this->userInput = $userInput; - $this->isLoggedIn = $isLoggedIn; - } - - /** - * Build data for feed templates. - * - * @return array Formatted data for feeds templates. - */ - public function buildData() - { - // Search for untagged links - if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { - $this->userInput['searchtags'] = false; - } - - // Optionally filter the results: - $linksToDisplay = $this->linkDB->filterSearch($this->userInput); - - $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); - - // Can't use array_keys() because $link is a LinkDB instance and not a real array. - $keys = array(); - foreach ($linksToDisplay as $key => $value) { - $keys[] = $key; - } - - $pageaddr = escape(index_url($this->serverInfo)); - $linkDisplayed = array(); - for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { - $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); - } - - $data['language'] = $this->getTypeLanguage(); - $data['last_update'] = $this->getLatestDateFormatted(); - $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; - // Remove leading slash from REQUEST_URI. - $data['self_link'] = escape(server_url($this->serverInfo)) - . escape($this->serverInfo['REQUEST_URI']); - $data['index_url'] = $pageaddr; - $data['usepermalinks'] = $this->usePermalinks === true; - $data['links'] = $linkDisplayed; - - return $data; - } - - /** - * Build a feed item (one per shaare). - * - * @param array $link Single link array extracted from LinkDB. - * @param string $pageaddr Index URL. - * - * @return array Link array with feed attributes. - */ - protected function buildItem($link, $pageaddr) - { - $link['guid'] = $pageaddr .'?'. $link['shorturl']; - // Check for both signs of a note: starting with ? and 7 chars long. - if ($link['url'][0] === '?' && strlen($link['url']) === 7) { - $link['url'] = $pageaddr . $link['url']; - } - if ($this->usePermalinks === true) { - $permalink = ''. t('Direct link') .''; - } else { - $permalink = ''. t('Permalink') .''; - } - $link['description'] = format_description($link['description'], '', false, $pageaddr); - $link['description'] .= PHP_EOL .'
— '. $permalink; - - $pubDate = $link['created']; - $link['pub_iso_date'] = $this->getIsoDate($pubDate); - - // atom:entry elements MUST contain exactly one atom:updated element. - if (!empty($link['updated'])) { - $upDate = $link['updated']; - $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM); - } else { - $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM); - ; - } - - // Save the more recent item. - if (empty($this->latestDate) || $this->latestDate < $pubDate) { - $this->latestDate = $pubDate; - } - if (!empty($upDate) && $this->latestDate < $upDate) { - $this->latestDate = $upDate; - } - - $taglist = array_filter(explode(' ', $link['tags']), 'strlen'); - uasort($taglist, 'strcasecmp'); - $link['taglist'] = $taglist; - - return $link; - } - - /** - * Set this to true to use permalinks instead of direct links. - * - * @param boolean $usePermalinks true to force permalinks. - */ - public function setUsePermalinks($usePermalinks) - { - $this->usePermalinks = $usePermalinks; - } - - /** - * Set this to true to hide timestamps in feeds. - * - * @param boolean $hideDates true to enable. - */ - public function setHideDates($hideDates) - { - $this->hideDates = $hideDates; - } - - /** - * Set the locale. Used to show feed language. - * - * @param string $locale The locale (eg. 'fr_FR.UTF8'). - */ - public function setLocale($locale) - { - $this->locale = strtolower($locale); - } - - /** - * Get the language according to the feed type, based on the locale: - * - * - RSS format: en-us (default: 'en-en'). - * - ATOM format: fr (default: 'en'). - * - * @return string The language. - */ - public function getTypeLanguage() - { - // Use the locale do define the language, if available. - if (! empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { - $length = ($this->feedType == self::$FEED_RSS) ? 5 : 2; - return str_replace('_', '-', substr($this->locale, 0, $length)); - } - return ($this->feedType == self::$FEED_RSS) ? 'en-en' : 'en'; - } - - /** - * Format the latest item date found according to the feed type. - * - * Return an empty string if invalid DateTime is passed. - * - * @return string Formatted date. - */ - protected function getLatestDateFormatted() - { - if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { - return ''; - } - - $type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; - return $this->latestDate->format($type); - } - - /** - * Get ISO date from DateTime according to feed type. - * - * @param DateTime $date Date to format. - * @param string|bool $format Force format. - * - * @return string Formatted date. - */ - protected function getIsoDate(DateTime $date, $format = false) - { - if ($format !== false) { - return $date->format($format); - } - if ($this->feedType == self::$FEED_RSS) { - return $date->format(DateTime::RSS); - } - return $date->format(DateTime::ATOM); - } - - /** - * Returns the number of link to display according to 'nb' user input parameter. - * - * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. - * If 'nb' is set to 'all', display all filtered links (max parameter). - * - * @param int $max maximum number of links to display. - * - * @return int number of links to display. - */ - public function getNbLinks($max) - { - if (empty($this->userInput['nb'])) { - return self::$DEFAULT_NB_LINKS; - } - - if ($this->userInput['nb'] == 'all') { - return $max; - } - - $intNb = intval($this->userInput['nb']); - if (! is_int($intNb) || $intNb == 0) { - return self::$DEFAULT_NB_LINKS; - } - - return $intNb; - } -} diff --git a/application/feed/Cache.php b/application/feed/Cache.php new file mode 100644 index 00000000..e5d43e61 --- /dev/null +++ b/application/feed/Cache.php @@ -0,0 +1,38 @@ +cacheDir = $cacheDir; + $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache'; + $this->shouldBeCached = $shouldBeCached; + } + + /** + * Returns the cached version of a page, if it exists and should be cached + * + * @return string a cached version of the page if it exists, null otherwise + */ + public function cachedVersion() + { + if (!$this->shouldBeCached) { + return null; + } + if (is_file($this->filename)) { + return file_get_contents($this->filename); + } + return null; + } + + /** + * Puts a page in the cache + * + * @param string $pageContent XML content to cache + */ + public function cache($pageContent) + { + if (!$this->shouldBeCached) { + return; + } + file_put_contents($this->filename, $pageContent); + } +} diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php new file mode 100644 index 00000000..dcfd2c89 --- /dev/null +++ b/application/feed/FeedBuilder.php @@ -0,0 +1,299 @@ +linkDB = $linkDB; + $this->feedType = $feedType; + $this->serverInfo = $serverInfo; + $this->userInput = $userInput; + $this->isLoggedIn = $isLoggedIn; + } + + /** + * Build data for feed templates. + * + * @return array Formatted data for feeds templates. + */ + public function buildData() + { + // Search for untagged links + if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { + $this->userInput['searchtags'] = false; + } + + // Optionally filter the results: + $linksToDisplay = $this->linkDB->filterSearch($this->userInput); + + $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); + + // Can't use array_keys() because $link is a LinkDB instance and not a real array. + $keys = array(); + foreach ($linksToDisplay as $key => $value) { + $keys[] = $key; + } + + $pageaddr = escape(index_url($this->serverInfo)); + $linkDisplayed = array(); + for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { + $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); + } + + $data['language'] = $this->getTypeLanguage(); + $data['last_update'] = $this->getLatestDateFormatted(); + $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; + // Remove leading slash from REQUEST_URI. + $data['self_link'] = escape(server_url($this->serverInfo)) + . escape($this->serverInfo['REQUEST_URI']); + $data['index_url'] = $pageaddr; + $data['usepermalinks'] = $this->usePermalinks === true; + $data['links'] = $linkDisplayed; + + return $data; + } + + /** + * Build a feed item (one per shaare). + * + * @param array $link Single link array extracted from LinkDB. + * @param string $pageaddr Index URL. + * + * @return array Link array with feed attributes. + */ + protected function buildItem($link, $pageaddr) + { + $link['guid'] = $pageaddr . '?' . $link['shorturl']; + // Check for both signs of a note: starting with ? and 7 chars long. + if ($link['url'][0] === '?' && strlen($link['url']) === 7) { + $link['url'] = $pageaddr . $link['url']; + } + if ($this->usePermalinks === true) { + $permalink = '' . t('Direct link') . ''; + } else { + $permalink = '' . t('Permalink') . ''; + } + $link['description'] = format_description($link['description'], '', false, $pageaddr); + $link['description'] .= PHP_EOL . '
— ' . $permalink; + + $pubDate = $link['created']; + $link['pub_iso_date'] = $this->getIsoDate($pubDate); + + // atom:entry elements MUST contain exactly one atom:updated element. + if (!empty($link['updated'])) { + $upDate = $link['updated']; + $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM); + } else { + $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM); + } + + // Save the more recent item. + if (empty($this->latestDate) || $this->latestDate < $pubDate) { + $this->latestDate = $pubDate; + } + if (!empty($upDate) && $this->latestDate < $upDate) { + $this->latestDate = $upDate; + } + + $taglist = array_filter(explode(' ', $link['tags']), 'strlen'); + uasort($taglist, 'strcasecmp'); + $link['taglist'] = $taglist; + + return $link; + } + + /** + * Set this to true to use permalinks instead of direct links. + * + * @param boolean $usePermalinks true to force permalinks. + */ + public function setUsePermalinks($usePermalinks) + { + $this->usePermalinks = $usePermalinks; + } + + /** + * Set this to true to hide timestamps in feeds. + * + * @param boolean $hideDates true to enable. + */ + public function setHideDates($hideDates) + { + $this->hideDates = $hideDates; + } + + /** + * Set the locale. Used to show feed language. + * + * @param string $locale The locale (eg. 'fr_FR.UTF8'). + */ + public function setLocale($locale) + { + $this->locale = strtolower($locale); + } + + /** + * Get the language according to the feed type, based on the locale: + * + * - RSS format: en-us (default: 'en-en'). + * - ATOM format: fr (default: 'en'). + * + * @return string The language. + */ + public function getTypeLanguage() + { + // Use the locale do define the language, if available. + if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { + $length = ($this->feedType === self::$FEED_RSS) ? 5 : 2; + return str_replace('_', '-', substr($this->locale, 0, $length)); + } + return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en'; + } + + /** + * Format the latest item date found according to the feed type. + * + * Return an empty string if invalid DateTime is passed. + * + * @return string Formatted date. + */ + protected function getLatestDateFormatted() + { + if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { + return ''; + } + + $type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; + return $this->latestDate->format($type); + } + + /** + * Get ISO date from DateTime according to feed type. + * + * @param DateTime $date Date to format. + * @param string|bool $format Force format. + * + * @return string Formatted date. + */ + protected function getIsoDate(DateTime $date, $format = false) + { + if ($format !== false) { + return $date->format($format); + } + if ($this->feedType == self::$FEED_RSS) { + return $date->format(DateTime::RSS); + } + return $date->format(DateTime::ATOM); + } + + /** + * Returns the number of link to display according to 'nb' user input parameter. + * + * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. + * If 'nb' is set to 'all', display all filtered links (max parameter). + * + * @param int $max maximum number of links to display. + * + * @return int number of links to display. + */ + public function getNbLinks($max) + { + if (empty($this->userInput['nb'])) { + return self::$DEFAULT_NB_LINKS; + } + + if ($this->userInput['nb'] == 'all') { + return $max; + } + + $intNb = intval($this->userInput['nb']); + if (!is_int($intNb) || $intNb == 0) { + return self::$DEFAULT_NB_LINKS; + } + + return $intNb; + } +} diff --git a/composer.json b/composer.json index 027203f4..7c9cbf3d 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "Shaarli\\Config\\": "application/config/", "Shaarli\\Config\\Exception\\": "application/config/exception", "Shaarli\\Exceptions\\": "application/exceptions", + "Shaarli\\Feed\\": "application/feed", "Shaarli\\Security\\": "application/security" } } diff --git a/index.php b/index.php index cc41d80c..6d1ae3fc 100644 --- a/index.php +++ b/index.php @@ -57,10 +57,8 @@ require_once __DIR__ . '/vendor/autoload.php'; // Shaarli library require_once 'application/ApplicationUtils.php'; -require_once 'application/Cache.php'; -require_once 'application/CachedPage.php'; require_once 'application/config/ConfigPlugin.php'; -require_once 'application/FeedBuilder.php'; +require_once 'application/feed/Cache.php'; require_once 'application/FileUtils.php'; require_once 'application/History.php'; require_once 'application/HttpUtils.php'; @@ -76,7 +74,9 @@ require_once 'application/PluginManager.php'; require_once 'application/Router.php'; require_once 'application/Updater.php'; use \Shaarli\Config\ConfigManager; -use Shaarli\History; +use \Shaarli\Feed\CachedPage; +use \Shaarli\Feed\FeedBuilder; +use \Shaarli\History; use \Shaarli\Languages; use \Shaarli\Security\LoginManager; use \Shaarli\Security\SessionManager; diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php index 9f0342a3..1872af8a 100644 --- a/plugins/pubsubhubbub/pubsubhubbub.php +++ b/plugins/pubsubhubbub/pubsubhubbub.php @@ -11,6 +11,7 @@ use pubsubhubbub\publisher\Publisher; use Shaarli\Config\ConfigManager; +use Shaarli\Feed\FeedBuilder; /** * Plugin init function - set the hub to the default appspot one. diff --git a/tests/CacheTest.php b/tests/CacheTest.php deleted file mode 100644 index f60fad91..00000000 --- a/tests/CacheTest.php +++ /dev/null @@ -1,91 +0,0 @@ -assertFileNotExists(self::$testCacheDir.'/'.$page.'.cache'); - } - - $this->assertFileExists(self::$testCacheDir.'/intru.der'); - } - - /** - * Purge cached pages - missing directory - */ - public function testPurgeCachedPagesMissingDir() - { - $oldlog = ini_get('error_log'); - ini_set('error_log', '/dev/null'); - $this->assertEquals( - 'Cannot purge sandbox/dummycache_missing: no directory', - purgeCachedPages(self::$testCacheDir.'_missing') - ); - ini_set('error_log', $oldlog); - } - - /** - * Purge cached pages and session cache - */ - public function testInvalidateCaches() - { - $this->assertArrayNotHasKey('tags', $_SESSION); - $_SESSION['tags'] = array('goodbye', 'cruel', 'world'); - - invalidateCaches(self::$testCacheDir); - foreach (self::$pages as $page) { - $this->assertFileNotExists(self::$testCacheDir.'/'.$page.'.cache'); - } - - $this->assertArrayNotHasKey('tags', $_SESSION); - } -} diff --git a/tests/CachedPageTest.php b/tests/CachedPageTest.php deleted file mode 100644 index 51565cd6..00000000 --- a/tests/CachedPageTest.php +++ /dev/null @@ -1,121 +0,0 @@ -assertFileNotExists(self::$filename); - $page->cache('

Some content

'); - $this->assertFileExists(self::$filename); - $this->assertEquals( - '

Some content

', - file_get_contents(self::$filename) - ); - } - - /** - * "Cache" a page's content - the page is not to be cached - */ - public function testShouldNotCache() - { - $page = new CachedPage(self::$testCacheDir, self::$url, false); - - $this->assertFileNotExists(self::$filename); - $page->cache('

Some content

'); - $this->assertFileNotExists(self::$filename); - } - - /** - * Return a page's cached content - */ - public function testCachedVersion() - { - $page = new CachedPage(self::$testCacheDir, self::$url, true); - - $this->assertFileNotExists(self::$filename); - $page->cache('

Some content

'); - $this->assertFileExists(self::$filename); - $this->assertEquals( - '

Some content

', - $page->cachedVersion() - ); - } - - /** - * Return a page's cached content - the file does not exist - */ - public function testCachedVersionNoFile() - { - $page = new CachedPage(self::$testCacheDir, self::$url, true); - - $this->assertFileNotExists(self::$filename); - $this->assertEquals( - null, - $page->cachedVersion() - ); - } - - /** - * Return a page's cached content - the page is not to be cached - */ - public function testNoCachedVersion() - { - $page = new CachedPage(self::$testCacheDir, self::$url, false); - - $this->assertFileNotExists(self::$filename); - $this->assertEquals( - null, - $page->cachedVersion() - ); - } -} diff --git a/tests/FeedBuilderTest.php b/tests/FeedBuilderTest.php deleted file mode 100644 index 4ca58e5a..00000000 --- a/tests/FeedBuilderTest.php +++ /dev/null @@ -1,245 +0,0 @@ -write(self::$testDatastore); - self::$linkDB = new LinkDB(self::$testDatastore, true, false); - self::$serverInfo = array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/index.php', - 'REQUEST_URI' => '/index.php?do=feed', - ); - } - - /** - * Test GetTypeLanguage(). - */ - public function testGetTypeLanguage() - { - $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_ATOM, null, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $this->assertEquals(self::$ATOM_LANGUAGUE, $feedBuilder->getTypeLanguage()); - $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_RSS, null, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $this->assertEquals(self::$RSS_LANGUAGE, $feedBuilder->getTypeLanguage()); - $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_ATOM, null, null, false); - $this->assertEquals('en', $feedBuilder->getTypeLanguage()); - $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_RSS, null, null, false); - $this->assertEquals('en-en', $feedBuilder->getTypeLanguage()); - } - - /** - * Test buildData with RSS feed. - */ - public function testRSSBuildData() - { - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_RSS, self::$serverInfo, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $data = $feedBuilder->buildData(); - // Test headers (RSS) - $this->assertEquals(self::$RSS_LANGUAGE, $data['language']); - $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']); - $this->assertEquals(true, $data['show_dates']); - $this->assertEquals('http://host.tld/index.php?do=feed', $data['self_link']); - $this->assertEquals('http://host.tld/', $data['index_url']); - $this->assertFalse($data['usepermalinks']); - $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); - - // Test first not pinned link (note link) - $link = $data['links'][array_keys($data['links'])[2]]; - $this->assertEquals(41, $link['id']); - $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); - $this->assertEquals('http://host.tld/?WDWyig', $link['guid']); - $this->assertEquals('http://host.tld/?WDWyig', $link['url']); - $this->assertRegExp('/Tue, 10 Mar 2015 11:46:51 \+\d{4}/', $link['pub_iso_date']); - $pub = DateTime::createFromFormat(DateTime::RSS, $link['pub_iso_date']); - $up = DateTime::createFromFormat(DateTime::ATOM, $link['up_iso_date']); - $this->assertEquals($pub, $up); - $this->assertContains('Stallman has a beard', $link['description']); - $this->assertContains('Permalink', $link['description']); - $this->assertContains('http://host.tld/?WDWyig', $link['description']); - $this->assertEquals(1, count($link['taglist'])); - $this->assertEquals('sTuff', $link['taglist'][0]); - - // Test URL with external link. - $this->assertEquals('https://static.fsf.org/nosvn/faif-2.0.pdf', $data['links'][8]['url']); - - // Test multitags. - $this->assertEquals(5, count($data['links'][6]['taglist'])); - $this->assertEquals('css', $data['links'][6]['taglist'][0]); - - // Test update date - $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['links'][8]['up_iso_date']); - } - - /** - * Test buildData with ATOM feed (test only specific to ATOM). - */ - public function testAtomBuildData() - { - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $data = $feedBuilder->buildData(); - $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); - $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['last_update']); - $link = $data['links'][array_keys($data['links'])[2]]; - $this->assertRegExp('/2015-03-10T11:46:51\+\d{2}:\d{2}/', $link['pub_iso_date']); - $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['links'][8]['up_iso_date']); - } - - /** - * Test buildData with search criteria. - */ - public function testBuildDataFiltered() - { - $criteria = array( - 'searchtags' => 'stuff', - 'searchterm' => 'beard', - ); - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, $criteria, false); - $feedBuilder->setLocale(self::$LOCALE); - $data = $feedBuilder->buildData(); - $this->assertEquals(1, count($data['links'])); - $link = array_shift($data['links']); - $this->assertEquals(41, $link['id']); - $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); - } - - /** - * Test buildData with nb limit. - */ - public function testBuildDataCount() - { - $criteria = array( - 'nb' => '3', - ); - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, $criteria, false); - $feedBuilder->setLocale(self::$LOCALE); - $data = $feedBuilder->buildData(); - $this->assertEquals(3, count($data['links'])); - $link = $data['links'][array_keys($data['links'])[2]]; - $this->assertEquals(41, $link['id']); - $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); - } - - /** - * Test buildData with permalinks on. - */ - public function testBuildDataPermalinks() - { - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $feedBuilder->setUsePermalinks(true); - $data = $feedBuilder->buildData(); - $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); - $this->assertTrue($data['usepermalinks']); - // First link is a permalink - $link = $data['links'][array_keys($data['links'])[2]]; - $this->assertEquals(41, $link['id']); - $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); - $this->assertEquals('http://host.tld/?WDWyig', $link['guid']); - $this->assertEquals('http://host.tld/?WDWyig', $link['url']); - $this->assertContains('Direct link', $link['description']); - $this->assertContains('http://host.tld/?WDWyig', $link['description']); - // Second link is a direct link - $link = $data['links'][array_keys($data['links'])[3]]; - $this->assertEquals(8, $link['id']); - $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114633'), $link['created']); - $this->assertEquals('http://host.tld/?RttfEw', $link['guid']); - $this->assertEquals('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['url']); - $this->assertContains('Direct link', $link['description']); - $this->assertContains('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['description']); - } - - /** - * Test buildData with hide dates settings. - */ - public function testBuildDataHideDates() - { - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); - $feedBuilder->setLocale(self::$LOCALE); - $feedBuilder->setHideDates(true); - $data = $feedBuilder->buildData(); - $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); - $this->assertFalse($data['show_dates']); - - // Show dates while logged in - $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, true); - $feedBuilder->setLocale(self::$LOCALE); - $feedBuilder->setHideDates(true); - $data = $feedBuilder->buildData(); - $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); - $this->assertTrue($data['show_dates']); - } - - /** - * Test buildData when Shaarli is served from a subdirectory - */ - public function testBuildDataServerSubdir() - { - $serverInfo = array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '8080', - 'SCRIPT_NAME' => '/~user/shaarli/index.php', - 'REQUEST_URI' => '/~user/shaarli/index.php?do=feed', - ); - $feedBuilder = new FeedBuilder( - self::$linkDB, - FeedBuilder::$FEED_ATOM, - $serverInfo, - null, - false - ); - $feedBuilder->setLocale(self::$LOCALE); - $data = $feedBuilder->buildData(); - - $this->assertEquals( - 'http://host.tld:8080/~user/shaarli/index.php?do=feed', - $data['self_link'] - ); - - // Test first link (note link) - $link = $data['links'][array_keys($data['links'])[2]]; - $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['guid']); - $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['url']); - $this->assertContains('http://host.tld:8080/~user/shaarli/?addtag=hashtag', $link['description']); - } -} diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php index 9b2f35e6..737a2247 100644 --- a/tests/LinkDBTest.php +++ b/tests/LinkDBTest.php @@ -3,9 +3,7 @@ * Link datastore tests */ -use Shaarli\Exceptions\IOException; - -require_once 'application/Cache.php'; +require_once 'application/feed/Cache.php'; require_once 'application/FileUtils.php'; require_once 'application/LinkDB.php'; require_once 'application/Utils.php'; diff --git a/tests/feed/CacheTest.php b/tests/feed/CacheTest.php new file mode 100644 index 00000000..c0a9f26f --- /dev/null +++ b/tests/feed/CacheTest.php @@ -0,0 +1,92 @@ +assertFileNotExists(self::$testCacheDir . '/' . $page . '.cache'); + } + + $this->assertFileExists(self::$testCacheDir . '/intru.der'); + } + + /** + * Purge cached pages - missing directory + */ + public function testPurgeCachedPagesMissingDir() + { + $oldlog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + $this->assertEquals( + 'Cannot purge sandbox/dummycache_missing: no directory', + purgeCachedPages(self::$testCacheDir . '_missing') + ); + ini_set('error_log', $oldlog); + } + + /** + * Purge cached pages and session cache + */ + public function testInvalidateCaches() + { + $this->assertArrayNotHasKey('tags', $_SESSION); + $_SESSION['tags'] = array('goodbye', 'cruel', 'world'); + + invalidateCaches(self::$testCacheDir); + foreach (self::$pages as $page) { + $this->assertFileNotExists(self::$testCacheDir . '/' . $page . '.cache'); + } + + $this->assertArrayNotHasKey('tags', $_SESSION); + } +} diff --git a/tests/feed/CachedPageTest.php b/tests/feed/CachedPageTest.php new file mode 100644 index 00000000..0bcc1442 --- /dev/null +++ b/tests/feed/CachedPageTest.php @@ -0,0 +1,120 @@ +assertFileNotExists(self::$filename); + $page->cache('

Some content

'); + $this->assertFileExists(self::$filename); + $this->assertEquals( + '

Some content

', + file_get_contents(self::$filename) + ); + } + + /** + * "Cache" a page's content - the page is not to be cached + */ + public function testShouldNotCache() + { + $page = new CachedPage(self::$testCacheDir, self::$url, false); + + $this->assertFileNotExists(self::$filename); + $page->cache('

Some content

'); + $this->assertFileNotExists(self::$filename); + } + + /** + * Return a page's cached content + */ + public function testCachedVersion() + { + $page = new CachedPage(self::$testCacheDir, self::$url, true); + + $this->assertFileNotExists(self::$filename); + $page->cache('

Some content

'); + $this->assertFileExists(self::$filename); + $this->assertEquals( + '

Some content

', + $page->cachedVersion() + ); + } + + /** + * Return a page's cached content - the file does not exist + */ + public function testCachedVersionNoFile() + { + $page = new CachedPage(self::$testCacheDir, self::$url, true); + + $this->assertFileNotExists(self::$filename); + $this->assertEquals( + null, + $page->cachedVersion() + ); + } + + /** + * Return a page's cached content - the page is not to be cached + */ + public function testNoCachedVersion() + { + $page = new CachedPage(self::$testCacheDir, self::$url, false); + + $this->assertFileNotExists(self::$filename); + $this->assertEquals( + null, + $page->cachedVersion() + ); + } +} diff --git a/tests/feed/FeedBuilderTest.php b/tests/feed/FeedBuilderTest.php new file mode 100644 index 00000000..1fdbc60e --- /dev/null +++ b/tests/feed/FeedBuilderTest.php @@ -0,0 +1,250 @@ +write(self::$testDatastore); + self::$linkDB = new LinkDB(self::$testDatastore, true, false); + self::$serverInfo = array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/index.php', + 'REQUEST_URI' => '/index.php?do=feed', + ); + } + + /** + * Test GetTypeLanguage(). + */ + public function testGetTypeLanguage() + { + $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_ATOM, null, null, false); + $feedBuilder->setLocale(self::$LOCALE); + $this->assertEquals(self::$ATOM_LANGUAGUE, $feedBuilder->getTypeLanguage()); + $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_RSS, null, null, false); + $feedBuilder->setLocale(self::$LOCALE); + $this->assertEquals(self::$RSS_LANGUAGE, $feedBuilder->getTypeLanguage()); + $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_ATOM, null, null, false); + $this->assertEquals('en', $feedBuilder->getTypeLanguage()); + $feedBuilder = new FeedBuilder(null, FeedBuilder::$FEED_RSS, null, null, false); + $this->assertEquals('en-en', $feedBuilder->getTypeLanguage()); + } + + /** + * Test buildData with RSS feed. + */ + public function testRSSBuildData() + { + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_RSS, self::$serverInfo, null, false); + $feedBuilder->setLocale(self::$LOCALE); + $data = $feedBuilder->buildData(); + // Test headers (RSS) + $this->assertEquals(self::$RSS_LANGUAGE, $data['language']); + $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']); + $this->assertEquals(true, $data['show_dates']); + $this->assertEquals('http://host.tld/index.php?do=feed', $data['self_link']); + $this->assertEquals('http://host.tld/', $data['index_url']); + $this->assertFalse($data['usepermalinks']); + $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); + + // Test first not pinned link (note link) + $link = $data['links'][array_keys($data['links'])[2]]; + $this->assertEquals(41, $link['id']); + $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); + $this->assertEquals('http://host.tld/?WDWyig', $link['guid']); + $this->assertEquals('http://host.tld/?WDWyig', $link['url']); + $this->assertRegExp('/Tue, 10 Mar 2015 11:46:51 \+\d{4}/', $link['pub_iso_date']); + $pub = DateTime::createFromFormat(DateTime::RSS, $link['pub_iso_date']); + $up = DateTime::createFromFormat(DateTime::ATOM, $link['up_iso_date']); + $this->assertEquals($pub, $up); + $this->assertContains('Stallman has a beard', $link['description']); + $this->assertContains('Permalink', $link['description']); + $this->assertContains('http://host.tld/?WDWyig', $link['description']); + $this->assertEquals(1, count($link['taglist'])); + $this->assertEquals('sTuff', $link['taglist'][0]); + + // Test URL with external link. + $this->assertEquals('https://static.fsf.org/nosvn/faif-2.0.pdf', $data['links'][8]['url']); + + // Test multitags. + $this->assertEquals(5, count($data['links'][6]['taglist'])); + $this->assertEquals('css', $data['links'][6]['taglist'][0]); + + // Test update date + $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['links'][8]['up_iso_date']); + } + + /** + * Test buildData with ATOM feed (test only specific to ATOM). + */ + public function testAtomBuildData() + { + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); + $feedBuilder->setLocale(self::$LOCALE); + $data = $feedBuilder->buildData(); + $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); + $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['last_update']); + $link = $data['links'][array_keys($data['links'])[2]]; + $this->assertRegExp('/2015-03-10T11:46:51\+\d{2}:\d{2}/', $link['pub_iso_date']); + $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['links'][8]['up_iso_date']); + } + + /** + * Test buildData with search criteria. + */ + public function testBuildDataFiltered() + { + $criteria = array( + 'searchtags' => 'stuff', + 'searchterm' => 'beard', + ); + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, $criteria, false); + $feedBuilder->setLocale(self::$LOCALE); + $data = $feedBuilder->buildData(); + $this->assertEquals(1, count($data['links'])); + $link = array_shift($data['links']); + $this->assertEquals(41, $link['id']); + $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); + } + + /** + * Test buildData with nb limit. + */ + public function testBuildDataCount() + { + $criteria = array( + 'nb' => '3', + ); + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, $criteria, false); + $feedBuilder->setLocale(self::$LOCALE); + $data = $feedBuilder->buildData(); + $this->assertEquals(3, count($data['links'])); + $link = $data['links'][array_keys($data['links'])[2]]; + $this->assertEquals(41, $link['id']); + $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); + } + + /** + * Test buildData with permalinks on. + */ + public function testBuildDataPermalinks() + { + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); + $feedBuilder->setLocale(self::$LOCALE); + $feedBuilder->setUsePermalinks(true); + $data = $feedBuilder->buildData(); + $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); + $this->assertTrue($data['usepermalinks']); + // First link is a permalink + $link = $data['links'][array_keys($data['links'])[2]]; + $this->assertEquals(41, $link['id']); + $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), $link['created']); + $this->assertEquals('http://host.tld/?WDWyig', $link['guid']); + $this->assertEquals('http://host.tld/?WDWyig', $link['url']); + $this->assertContains('Direct link', $link['description']); + $this->assertContains('http://host.tld/?WDWyig', $link['description']); + // Second link is a direct link + $link = $data['links'][array_keys($data['links'])[3]]; + $this->assertEquals(8, $link['id']); + $this->assertEquals(DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114633'), $link['created']); + $this->assertEquals('http://host.tld/?RttfEw', $link['guid']); + $this->assertEquals('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['url']); + $this->assertContains('Direct link', $link['description']); + $this->assertContains('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['description']); + } + + /** + * Test buildData with hide dates settings. + */ + public function testBuildDataHideDates() + { + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, false); + $feedBuilder->setLocale(self::$LOCALE); + $feedBuilder->setHideDates(true); + $data = $feedBuilder->buildData(); + $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); + $this->assertFalse($data['show_dates']); + + // Show dates while logged in + $feedBuilder = new FeedBuilder(self::$linkDB, FeedBuilder::$FEED_ATOM, self::$serverInfo, null, true); + $feedBuilder->setLocale(self::$LOCALE); + $feedBuilder->setHideDates(true); + $data = $feedBuilder->buildData(); + $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links'])); + $this->assertTrue($data['show_dates']); + } + + /** + * Test buildData when Shaarli is served from a subdirectory + */ + public function testBuildDataServerSubdir() + { + $serverInfo = array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '8080', + 'SCRIPT_NAME' => '/~user/shaarli/index.php', + 'REQUEST_URI' => '/~user/shaarli/index.php?do=feed', + ); + $feedBuilder = new FeedBuilder( + self::$linkDB, + FeedBuilder::$FEED_ATOM, + $serverInfo, + null, + false + ); + $feedBuilder->setLocale(self::$LOCALE); + $data = $feedBuilder->buildData(); + + $this->assertEquals( + 'http://host.tld:8080/~user/shaarli/index.php?do=feed', + $data['self_link'] + ); + + // Test first link (note link) + $link = $data['links'][array_keys($data['links'])[2]]; + $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['guid']); + $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['url']); + $this->assertContains('http://host.tld:8080/~user/shaarli/?addtag=hashtag', $link['description']); + } +} -- cgit v1.2.3 From 00af48d9d20af1ce51c8ad42fe354fafc9ceb8a3 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 3 Dec 2018 00:16:10 +0100 Subject: namespacing: \Shaarli\Http\Base64Url Signed-off-by: VirtualTam --- application/Base64Url.php | 35 ----------------------------------- application/api/ApiUtils.php | 6 +++--- application/http/Base64Url.php | 35 +++++++++++++++++++++++++++++++++++ composer.json | 1 + tests/api/ApiUtilsTest.php | 2 +- 5 files changed, 40 insertions(+), 39 deletions(-) delete mode 100644 application/Base64Url.php create mode 100644 application/http/Base64Url.php diff --git a/application/Base64Url.php b/application/Base64Url.php deleted file mode 100644 index 54d0fcd5..00000000 --- a/application/Base64Url.php +++ /dev/null @@ -1,35 +0,0 @@ - Date: Mon, 3 Dec 2018 00:23:35 +0100 Subject: namespacing: \Shaarli\Http\Url Signed-off-by: VirtualTam --- application/HttpUtils.php | 3 + application/Url.php | 218 +--------------------------------------------- application/http/Url.php | 217 +++++++++++++++++++++++++++++++++++++++++++++ tests/Url/UrlTest.php | 200 ------------------------------------------ tests/http/UrlTest.php | 201 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 423 insertions(+), 416 deletions(-) create mode 100644 application/http/Url.php delete mode 100644 tests/Url/UrlTest.php create mode 100644 tests/http/UrlTest.php diff --git a/application/HttpUtils.php b/application/HttpUtils.php index 9c438160..51af5d0d 100644 --- a/application/HttpUtils.php +++ b/application/HttpUtils.php @@ -1,4 +1,7 @@ cleanup(); } @@ -47,7 +47,7 @@ function cleanup_url($url) */ function get_url_scheme($url) { - $obj_url = new Url($url); + $obj_url = new \Shaarli\Http\Url($url); return $obj_url->getScheme(); } @@ -86,217 +86,3 @@ function whitelist_protocols($url, $protocols) } return $url; } - -/** - * URL representation and cleanup utilities - * - * Form - * scheme://[username:password@]host[:port][/path][?query][#fragment] - * - * Examples - * http://username:password@hostname:9090/path?arg1=value1&arg2=value2#anchor - * https://host.name.tld - * https://h2.g2/faq/?vendor=hitchhiker&item=guide&dest=galaxy#answer - * - * @see http://www.faqs.org/rfcs/rfc3986.html - */ -class Url -{ - private static $annoyingQueryParams = array( - // Facebook - 'action_object_map=', - 'action_ref_map=', - 'action_type_map=', - 'fb_', - 'fb=', - 'PHPSESSID=', - - // Scoop.it - '__scoop', - - // Google Analytics & FeedProxy - 'utm_', - - // ATInternet - 'xtor=', - - // Other - 'campaign_' - ); - - private static $annoyingFragments = array( - // ATInternet - 'xtor=RSS-', - - // Misc. - 'tk.rss_all' - ); - - /* - * URL parts represented as an array - * - * @see http://php.net/parse_url - */ - protected $parts; - - /** - * Parses a string containing a URL - * - * @param string $url a string containing a URL - */ - public function __construct($url) - { - $url = self::cleanupUnparsedUrl(trim($url)); - $this->parts = parse_url($url); - - if (!empty($url) && empty($this->parts['scheme'])) { - $this->parts['scheme'] = 'http'; - } - } - - /** - * Clean up URL before it's parsed. - * ie. handle urlencode, url prefixes, etc. - * - * @param string $url URL to clean. - * - * @return string cleaned URL. - */ - protected static function cleanupUnparsedUrl($url) - { - return self::removeFirefoxAboutReader($url); - } - - /** - * Remove Firefox Reader prefix if it's present. - * - * @param string $input url - * - * @return string cleaned url - */ - protected static function removeFirefoxAboutReader($input) - { - $firefoxPrefix = 'about://reader?url='; - if (startsWith($input, $firefoxPrefix)) { - return urldecode(ltrim($input, $firefoxPrefix)); - } - return $input; - } - - /** - * Returns a string representation of this URL - */ - public function toString() - { - return unparse_url($this->parts); - } - - /** - * Removes undesired query parameters - */ - protected function cleanupQuery() - { - if (! isset($this->parts['query'])) { - return; - } - - $queryParams = explode('&', $this->parts['query']); - - foreach (self::$annoyingQueryParams as $annoying) { - foreach ($queryParams as $param) { - if (startsWith($param, $annoying)) { - $queryParams = array_diff($queryParams, array($param)); - continue; - } - } - } - - if (count($queryParams) == 0) { - unset($this->parts['query']); - return; - } - - $this->parts['query'] = implode('&', $queryParams); - } - - /** - * Removes undesired fragments - */ - protected function cleanupFragment() - { - if (! isset($this->parts['fragment'])) { - return; - } - - foreach (self::$annoyingFragments as $annoying) { - if (startsWith($this->parts['fragment'], $annoying)) { - unset($this->parts['fragment']); - break; - } - } - } - - /** - * Removes undesired query parameters and fragments - * - * @return string the string representation of this URL after cleanup - */ - public function cleanup() - { - $this->cleanupQuery(); - $this->cleanupFragment(); - return $this->toString(); - } - - /** - * Converts an URL with an International Domain Name host to a ASCII one. - * This requires PHP-intl. If it's not available, just returns this->cleanup(). - * - * @return string converted cleaned up URL. - */ - public function idnToAscii() - { - $out = $this->cleanup(); - if (! function_exists('idn_to_ascii') || ! isset($this->parts['host'])) { - return $out; - } - $asciiHost = idn_to_ascii($this->parts['host'], 0, INTL_IDNA_VARIANT_UTS46); - return str_replace($this->parts['host'], $asciiHost, $out); - } - - /** - * Get URL scheme. - * - * @return string the URL scheme or false if none is provided. - */ - public function getScheme() - { - if (!isset($this->parts['scheme'])) { - return false; - } - return $this->parts['scheme']; - } - - /** - * Get URL host. - * - * @return string the URL host or false if none is provided. - */ - public function getHost() - { - if (empty($this->parts['host'])) { - return false; - } - return $this->parts['host']; - } - - /** - * Test if the Url is an HTTP one. - * - * @return true is HTTP, false otherwise. - */ - public function isHttp() - { - return strpos(strtolower($this->parts['scheme']), 'http') !== false; - } -} diff --git a/application/http/Url.php b/application/http/Url.php new file mode 100644 index 00000000..260231c6 --- /dev/null +++ b/application/http/Url.php @@ -0,0 +1,217 @@ +parts = parse_url($url); + + if (!empty($url) && empty($this->parts['scheme'])) { + $this->parts['scheme'] = 'http'; + } + } + + /** + * Clean up URL before it's parsed. + * ie. handle urlencode, url prefixes, etc. + * + * @param string $url URL to clean. + * + * @return string cleaned URL. + */ + protected static function cleanupUnparsedUrl($url) + { + return self::removeFirefoxAboutReader($url); + } + + /** + * Remove Firefox Reader prefix if it's present. + * + * @param string $input url + * + * @return string cleaned url + */ + protected static function removeFirefoxAboutReader($input) + { + $firefoxPrefix = 'about://reader?url='; + if (startsWith($input, $firefoxPrefix)) { + return urldecode(ltrim($input, $firefoxPrefix)); + } + return $input; + } + + /** + * Returns a string representation of this URL + */ + public function toString() + { + return unparse_url($this->parts); + } + + /** + * Removes undesired query parameters + */ + protected function cleanupQuery() + { + if (!isset($this->parts['query'])) { + return; + } + + $queryParams = explode('&', $this->parts['query']); + + foreach (self::$annoyingQueryParams as $annoying) { + foreach ($queryParams as $param) { + if (startsWith($param, $annoying)) { + $queryParams = array_diff($queryParams, array($param)); + continue; + } + } + } + + if (count($queryParams) == 0) { + unset($this->parts['query']); + return; + } + + $this->parts['query'] = implode('&', $queryParams); + } + + /** + * Removes undesired fragments + */ + protected function cleanupFragment() + { + if (!isset($this->parts['fragment'])) { + return; + } + + foreach (self::$annoyingFragments as $annoying) { + if (startsWith($this->parts['fragment'], $annoying)) { + unset($this->parts['fragment']); + break; + } + } + } + + /** + * Removes undesired query parameters and fragments + * + * @return string the string representation of this URL after cleanup + */ + public function cleanup() + { + $this->cleanupQuery(); + $this->cleanupFragment(); + return $this->toString(); + } + + /** + * Converts an URL with an International Domain Name host to a ASCII one. + * This requires PHP-intl. If it's not available, just returns this->cleanup(). + * + * @return string converted cleaned up URL. + */ + public function idnToAscii() + { + $out = $this->cleanup(); + if (!function_exists('idn_to_ascii') || !isset($this->parts['host'])) { + return $out; + } + $asciiHost = idn_to_ascii($this->parts['host'], 0, INTL_IDNA_VARIANT_UTS46); + return str_replace($this->parts['host'], $asciiHost, $out); + } + + /** + * Get URL scheme. + * + * @return string the URL scheme or false if none is provided. + */ + public function getScheme() + { + if (!isset($this->parts['scheme'])) { + return false; + } + return $this->parts['scheme']; + } + + /** + * Get URL host. + * + * @return string the URL host or false if none is provided. + */ + public function getHost() + { + if (empty($this->parts['host'])) { + return false; + } + return $this->parts['host']; + } + + /** + * Test if the Url is an HTTP one. + * + * @return true is HTTP, false otherwise. + */ + public function isHttp() + { + return strpos(strtolower($this->parts['scheme']), 'http') !== false; + } +} diff --git a/tests/Url/UrlTest.php b/tests/Url/UrlTest.php deleted file mode 100644 index db229ce0..00000000 --- a/tests/Url/UrlTest.php +++ /dev/null @@ -1,200 +0,0 @@ -cleanup(); - $this->assertEquals(self::$baseUrl, $url->toString()); - } - - /** - * Instantiate an empty URL - */ - public function testEmptyConstruct() - { - $url = new Url(''); - $this->assertEquals('', $url->toString()); - } - - /** - * Instantiate a URL - */ - public function testConstruct() - { - $ref = 'http://username:password@hostname:9090/path' - .'?arg1=value1&arg2=value2#anchor'; - $url = new Url($ref); - $this->assertEquals($ref, $url->toString()); - } - - /** - * URL cleanup - nothing to do - */ - public function testNoCleanup() - { - // URL with no query nor fragment - $this->assertUrlIsCleaned(); - - // URL with no annoying elements - $ref = self::$baseUrl.'?p1=val1&p2=1234#edit'; - $url = new Url($ref); - $this->assertEquals($ref, $url->cleanup()); - } - - /** - * URL cleanup - annoying fragment - */ - public function testCleanupFragment() - { - $this->assertUrlIsCleaned('', '#tk.rss_all'); - $this->assertUrlIsCleaned('', '#xtor=RSS-'); - $this->assertUrlIsCleaned('', '#xtor=RSS-U3ht0tkc4b'); - } - - /** - * URL cleanup - single annoying query parameter - */ - public function testCleanupSingleQueryParam() - { - $this->assertUrlIsCleaned('?action_object_map=junk'); - $this->assertUrlIsCleaned('?action_ref_map=Cr4p!'); - $this->assertUrlIsCleaned('?action_type_map=g4R84g3'); - - $this->assertUrlIsCleaned('?fb_stuff=v41u3'); - $this->assertUrlIsCleaned('?fb=71m3w4573'); - - $this->assertUrlIsCleaned('?utm_campaign=zomg'); - $this->assertUrlIsCleaned('?utm_medium=numnum'); - $this->assertUrlIsCleaned('?utm_source=c0d3'); - $this->assertUrlIsCleaned('?utm_term=1n4l'); - - $this->assertUrlIsCleaned('?xtor=some-url'); - $this->assertUrlIsCleaned('?PHPSESSID=012345678910111213'); - } - - /** - * URL cleanup - multiple annoying query parameters - */ - public function testCleanupMultipleQueryParams() - { - $this->assertUrlIsCleaned('?xtor=some-url&fb=som3th1ng'); - $this->assertUrlIsCleaned( - '?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3' - ); - } - - /** - * URL cleanup - multiple annoying query parameters, annoying fragment - */ - public function testCleanupMultipleQueryParamsAndFragment() - { - $this->assertUrlIsCleaned('?xtor=some-url&fb=som3th1ng', '#tk.rss_all'); - } - - /** - * Nominal case - the URL contains both useful and annoying parameters - */ - public function testCleanupMixedContent() - { - // ditch annoying query params and fragment, keep useful params - $url = new Url( - self::$baseUrl - .'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all' - ); - $this->assertEquals(self::$baseUrl.'?my=stuff&is=kept', $url->cleanup()); - - - // ditch annoying query params, keep useful params and fragment - $url = new Url( - self::$baseUrl - .'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again' - ); - $this->assertEquals( - self::$baseUrl.'?my=stuff&is=kept#again', - $url->cleanup() - ); - - // test firefox reader url - $url = new Url( - 'about://reader?url=' . urlencode(self::$baseUrl .'?my=stuff&is=kept') - ); - $this->assertEquals(self::$baseUrl.'?my=stuff&is=kept', $url->cleanup()); - } - - /** - * Test default http scheme. - */ - public function testDefaultScheme() - { - $url = new Url(self::$baseUrl); - $this->assertEquals('http', $url->getScheme()); - $url = new Url('domain.tld'); - $this->assertEquals('http', $url->getScheme()); - $url = new Url('ssh://domain.tld'); - $this->assertEquals('ssh', $url->getScheme()); - $url = new Url('ftp://domain.tld'); - $this->assertEquals('ftp', $url->getScheme()); - $url = new Url('git://domain.tld/push?pull=clone#checkout'); - $this->assertEquals('git', $url->getScheme()); - } - - /** - * Test add trailing slash. - */ - public function testAddTrailingSlash() - { - $strOn = 'http://randomstr.com/test/'; - $strOff = 'http://randomstr.com/test'; - $this->assertEquals($strOn, add_trailing_slash($strOn)); - $this->assertEquals($strOn, add_trailing_slash($strOff)); - } - - /** - * Test valid HTTP url. - */ - public function testUrlIsHttp() - { - $url = new Url(self::$baseUrl); - $this->assertTrue($url->isHttp()); - } - - /** - * Test non HTTP url. - */ - public function testUrlIsNotHttp() - { - $url = new Url('ftp://save.tld/mysave'); - $this->assertFalse($url->isHttp()); - } - - /** - * Test International Domain Name to ASCII conversion - */ - public function testIdnToAscii() - { - $ind = 'http://www.académie-française.fr/'; - $expected = 'http://www.xn--acadmie-franaise-npb1a.fr/'; - $url = new Url($ind); - $this->assertEquals($expected, $url->idnToAscii()); - - $notInd = 'http://www.academie-francaise.fr/'; - $url = new Url($notInd); - $this->assertEquals($notInd, $url->idnToAscii()); - } -} diff --git a/tests/http/UrlTest.php b/tests/http/UrlTest.php new file mode 100644 index 00000000..011b416d --- /dev/null +++ b/tests/http/UrlTest.php @@ -0,0 +1,201 @@ +cleanup(); + $this->assertEquals(self::$baseUrl, $url->toString()); + } + + /** + * Instantiate an empty URL + */ + public function testEmptyConstruct() + { + $url = new Url(''); + $this->assertEquals('', $url->toString()); + } + + /** + * Instantiate a URL + */ + public function testConstruct() + { + $ref = 'http://username:password@hostname:9090/path' + . '?arg1=value1&arg2=value2#anchor'; + $url = new Url($ref); + $this->assertEquals($ref, $url->toString()); + } + + /** + * URL cleanup - nothing to do + */ + public function testNoCleanup() + { + // URL with no query nor fragment + $this->assertUrlIsCleaned(); + + // URL with no annoying elements + $ref = self::$baseUrl . '?p1=val1&p2=1234#edit'; + $url = new Url($ref); + $this->assertEquals($ref, $url->cleanup()); + } + + /** + * URL cleanup - annoying fragment + */ + public function testCleanupFragment() + { + $this->assertUrlIsCleaned('', '#tk.rss_all'); + $this->assertUrlIsCleaned('', '#xtor=RSS-'); + $this->assertUrlIsCleaned('', '#xtor=RSS-U3ht0tkc4b'); + } + + /** + * URL cleanup - single annoying query parameter + */ + public function testCleanupSingleQueryParam() + { + $this->assertUrlIsCleaned('?action_object_map=junk'); + $this->assertUrlIsCleaned('?action_ref_map=Cr4p!'); + $this->assertUrlIsCleaned('?action_type_map=g4R84g3'); + + $this->assertUrlIsCleaned('?fb_stuff=v41u3'); + $this->assertUrlIsCleaned('?fb=71m3w4573'); + + $this->assertUrlIsCleaned('?utm_campaign=zomg'); + $this->assertUrlIsCleaned('?utm_medium=numnum'); + $this->assertUrlIsCleaned('?utm_source=c0d3'); + $this->assertUrlIsCleaned('?utm_term=1n4l'); + + $this->assertUrlIsCleaned('?xtor=some-url'); + $this->assertUrlIsCleaned('?PHPSESSID=012345678910111213'); + } + + /** + * URL cleanup - multiple annoying query parameters + */ + public function testCleanupMultipleQueryParams() + { + $this->assertUrlIsCleaned('?xtor=some-url&fb=som3th1ng'); + $this->assertUrlIsCleaned( + '?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3' + ); + } + + /** + * URL cleanup - multiple annoying query parameters, annoying fragment + */ + public function testCleanupMultipleQueryParamsAndFragment() + { + $this->assertUrlIsCleaned('?xtor=some-url&fb=som3th1ng', '#tk.rss_all'); + } + + /** + * Nominal case - the URL contains both useful and annoying parameters + */ + public function testCleanupMixedContent() + { + // ditch annoying query params and fragment, keep useful params + $url = new Url( + self::$baseUrl + . '?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all' + ); + $this->assertEquals(self::$baseUrl . '?my=stuff&is=kept', $url->cleanup()); + + + // ditch annoying query params, keep useful params and fragment + $url = new Url( + self::$baseUrl + . '?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again' + ); + $this->assertEquals( + self::$baseUrl . '?my=stuff&is=kept#again', + $url->cleanup() + ); + + // test firefox reader url + $url = new Url( + 'about://reader?url=' . urlencode(self::$baseUrl . '?my=stuff&is=kept') + ); + $this->assertEquals(self::$baseUrl . '?my=stuff&is=kept', $url->cleanup()); + } + + /** + * Test default http scheme. + */ + public function testDefaultScheme() + { + $url = new Url(self::$baseUrl); + $this->assertEquals('http', $url->getScheme()); + $url = new Url('domain.tld'); + $this->assertEquals('http', $url->getScheme()); + $url = new Url('ssh://domain.tld'); + $this->assertEquals('ssh', $url->getScheme()); + $url = new Url('ftp://domain.tld'); + $this->assertEquals('ftp', $url->getScheme()); + $url = new Url('git://domain.tld/push?pull=clone#checkout'); + $this->assertEquals('git', $url->getScheme()); + } + + /** + * Test add trailing slash. + */ + public function testAddTrailingSlash() + { + $strOn = 'http://randomstr.com/test/'; + $strOff = 'http://randomstr.com/test'; + $this->assertEquals($strOn, add_trailing_slash($strOn)); + $this->assertEquals($strOn, add_trailing_slash($strOff)); + } + + /** + * Test valid HTTP url. + */ + public function testUrlIsHttp() + { + $url = new Url(self::$baseUrl); + $this->assertTrue($url->isHttp()); + } + + /** + * Test non HTTP url. + */ + public function testUrlIsNotHttp() + { + $url = new Url('ftp://save.tld/mysave'); + $this->assertFalse($url->isHttp()); + } + + /** + * Test International Domain Name to ASCII conversion + */ + public function testIdnToAscii() + { + $ind = 'http://www.académie-française.fr/'; + $expected = 'http://www.xn--acadmie-franaise-npb1a.fr/'; + $url = new Url($ind); + $this->assertEquals($expected, $url->idnToAscii()); + + $notInd = 'http://www.academie-francaise.fr/'; + $url = new Url($notInd); + $this->assertEquals($notInd, $url->idnToAscii()); + } +} -- cgit v1.2.3 From 51753e403fa69c0ce124ede27d300477e3e799ca Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 3 Dec 2018 00:34:53 +0100 Subject: namespacing: move HTTP utilities along \Shaarli\Http\ classes Signed-off-by: VirtualTam --- application/HttpUtils.php | 479 ---------------------- application/Url.php | 88 ---- application/http/HttpUtils.php | 479 ++++++++++++++++++++++ application/http/Url.php | 2 +- application/http/UrlUtils.php | 88 ++++ index.php | 4 +- tests/HttpUtils/ClientIpIdTest.php | 52 --- tests/HttpUtils/GetHttpUrlTest.php | 65 --- tests/HttpUtils/GetIpAdressFromProxyTest.php | 59 --- tests/HttpUtils/IndexUrlTest.php | 72 ---- tests/HttpUtils/IsHttpsTest.php | 36 -- tests/HttpUtils/PageUrlTest.php | 76 ---- tests/HttpUtils/ServerUrlTest.php | 221 ---------- tests/Url/CleanupUrlTest.php | 109 ----- tests/Url/GetUrlSchemeTest.php | 30 -- tests/Url/UnparseUrlTest.php | 30 -- tests/Url/WhitelistProtocolsTest.php | 63 --- tests/http/HttpUtils/ClientIpIdTest.php | 54 +++ tests/http/HttpUtils/GetHttpUrlTest.php | 67 +++ tests/http/HttpUtils/GetIpAdressFromProxyTest.php | 61 +++ tests/http/HttpUtils/IndexUrlTest.php | 74 ++++ tests/http/HttpUtils/IsHttpsTest.php | 39 ++ tests/http/HttpUtils/PageUrlTest.php | 78 ++++ tests/http/HttpUtils/ServerUrlTest.php | 223 ++++++++++ tests/http/UrlTest.php | 2 +- tests/http/UrlUtils/CleanupUrlTest.php | 111 +++++ tests/http/UrlUtils/GetUrlSchemeTest.php | 32 ++ tests/http/UrlUtils/UnparseUrlTest.php | 32 ++ tests/http/UrlUtils/WhitelistProtocolsTest.php | 63 +++ 29 files changed, 1405 insertions(+), 1384 deletions(-) delete mode 100644 application/HttpUtils.php delete mode 100644 application/Url.php create mode 100644 application/http/HttpUtils.php create mode 100644 application/http/UrlUtils.php delete mode 100644 tests/HttpUtils/ClientIpIdTest.php delete mode 100644 tests/HttpUtils/GetHttpUrlTest.php delete mode 100644 tests/HttpUtils/GetIpAdressFromProxyTest.php delete mode 100644 tests/HttpUtils/IndexUrlTest.php delete mode 100644 tests/HttpUtils/IsHttpsTest.php delete mode 100644 tests/HttpUtils/PageUrlTest.php delete mode 100644 tests/HttpUtils/ServerUrlTest.php delete mode 100644 tests/Url/CleanupUrlTest.php delete mode 100644 tests/Url/GetUrlSchemeTest.php delete mode 100644 tests/Url/UnparseUrlTest.php delete mode 100644 tests/Url/WhitelistProtocolsTest.php create mode 100644 tests/http/HttpUtils/ClientIpIdTest.php create mode 100644 tests/http/HttpUtils/GetHttpUrlTest.php create mode 100644 tests/http/HttpUtils/GetIpAdressFromProxyTest.php create mode 100644 tests/http/HttpUtils/IndexUrlTest.php create mode 100644 tests/http/HttpUtils/IsHttpsTest.php create mode 100644 tests/http/HttpUtils/PageUrlTest.php create mode 100644 tests/http/HttpUtils/ServerUrlTest.php create mode 100644 tests/http/UrlUtils/CleanupUrlTest.php create mode 100644 tests/http/UrlUtils/GetUrlSchemeTest.php create mode 100644 tests/http/UrlUtils/UnparseUrlTest.php create mode 100644 tests/http/UrlUtils/WhitelistProtocolsTest.php diff --git a/application/HttpUtils.php b/application/HttpUtils.php deleted file mode 100644 index 51af5d0d..00000000 --- a/application/HttpUtils.php +++ /dev/null @@ -1,479 +0,0 @@ -idnToAscii(); - - if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { - return array(array(0 => 'Invalid HTTP Url'), false); - } - - $userAgent = - 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)' - . ' Gecko/20100101 Firefox/45.0'; - $acceptLanguage = - substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3'; - $maxRedirs = 3; - - if (!function_exists('curl_init')) { - return get_http_response_fallback( - $cleanUrl, - $timeout, - $maxBytes, - $userAgent, - $acceptLanguage, - $maxRedirs - ); - } - - $ch = curl_init($cleanUrl); - if ($ch === false) { - return array(array(0 => 'curl_init() error'), false); - } - - // General cURL settings - curl_setopt($ch, CURLOPT_AUTOREFERER, true); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_HEADER, true); - curl_setopt( - $ch, - CURLOPT_HTTPHEADER, - array('Accept-Language: ' . $acceptLanguage) - ); - curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); - curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); - - if (is_callable($curlWriteFunction)) { - curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); - } - - // Max download size management - curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); - curl_setopt($ch, CURLOPT_NOPROGRESS, false); - curl_setopt( - $ch, - CURLOPT_PROGRESSFUNCTION, - function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { - if (version_compare(phpversion(), '5.5', '<')) { - // PHP version lower than 5.5 - // Callback has 4 arguments - $downloaded = $arg1; - } else { - // Callback has 5 arguments - $downloaded = $arg2; - } - // Non-zero return stops downloading - return ($downloaded > $maxBytes) ? 1 : 0; - } - ); - - $response = curl_exec($ch); - $errorNo = curl_errno($ch); - $errorStr = curl_error($ch); - $headSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); - curl_close($ch); - - if ($response === false) { - if ($errorNo == CURLE_COULDNT_RESOLVE_HOST) { - /* - * Workaround to match fallback method behaviour - * Removing this would require updating - * GetHttpUrlTest::testGetInvalidRemoteUrl() - */ - return array(false, false); - } - return array(array(0 => 'curl_exec() error: ' . $errorStr), false); - } - - // Formatting output like the fallback method - $rawHeaders = substr($response, 0, $headSize); - - // Keep only headers from latest redirection - $rawHeadersArrayRedirs = explode("\r\n\r\n", trim($rawHeaders)); - $rawHeadersLastRedir = end($rawHeadersArrayRedirs); - - $content = substr($response, $headSize); - $headers = array(); - foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { - if (empty($line) || ctype_space($line)) { - continue; - } - $splitLine = explode(': ', $line, 2); - if (count($splitLine) > 1) { - $key = $splitLine[0]; - $value = $splitLine[1]; - if (array_key_exists($key, $headers)) { - if (!is_array($headers[$key])) { - $headers[$key] = array(0 => $headers[$key]); - } - $headers[$key][] = $value; - } else { - $headers[$key] = $value; - } - } else { - $headers[] = $splitLine[0]; - } - } - - return array($headers, $content); -} - -/** - * GET an HTTP URL to retrieve its content (fallback method) - * - * @param string $cleanUrl URL to get (http://... valid and in ASCII form) - * @param int $timeout network timeout (in seconds) - * @param int $maxBytes maximum downloaded bytes - * @param string $userAgent "User-Agent" header - * @param string $acceptLanguage "Accept-Language" header - * @param int $maxRedr maximum amount of redirections followed - * - * @return array HTTP response headers, downloaded content - * - * Output format: - * [0] = associative array containing HTTP response headers - * [1] = URL content (downloaded data) - * - * @see http://php.net/manual/en/function.file-get-contents.php - * @see http://php.net/manual/en/function.stream-context-create.php - * @see http://php.net/manual/en/function.get-headers.php - */ -function get_http_response_fallback( - $cleanUrl, - $timeout, - $maxBytes, - $userAgent, - $acceptLanguage, - $maxRedr -) { - $options = array( - 'http' => array( - 'method' => 'GET', - 'timeout' => $timeout, - 'user_agent' => $userAgent, - 'header' => "Accept: */*\r\n" - . 'Accept-Language: ' . $acceptLanguage - ) - ); - - stream_context_set_default($options); - list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); - if (! $headers || strpos($headers[0], '200 OK') === false) { - $options['http']['request_fulluri'] = true; - stream_context_set_default($options); - list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); - } - - if (! $headers) { - return array($headers, false); - } - - try { - // TODO: catch Exception in calling code (thumbnailer) - $context = stream_context_create($options); - $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); - } catch (Exception $exc) { - return array(array(0 => 'HTTP Error'), $exc->getMessage()); - } - - return array($headers, $content); -} - -/** - * Retrieve HTTP headers, following n redirections (temporary and permanent ones). - * - * @param string $url initial URL to reach. - * @param int $redirectionLimit max redirection follow. - * - * @return array HTTP headers, or false if it failed. - */ -function get_redirected_headers($url, $redirectionLimit = 3) -{ - $headers = get_headers($url, 1); - if (!empty($headers['location']) && empty($headers['Location'])) { - $headers['Location'] = $headers['location']; - } - - // Headers found, redirection found, and limit not reached. - if ($redirectionLimit-- > 0 - && !empty($headers) - && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) - && !empty($headers['Location'])) { - $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; - if ($redirection != $url) { - $redirection = getAbsoluteUrl($url, $redirection); - return get_redirected_headers($redirection, $redirectionLimit); - } - } - - return array($headers, $url); -} - -/** - * Get an absolute URL from a complete one, and another absolute/relative URL. - * - * @param string $originalUrl The original complete URL. - * @param string $newUrl The new one, absolute or relative. - * - * @return string Final URL: - * - $newUrl if it was already an absolute URL. - * - if it was relative, absolute URL from $originalUrl path. - */ -function getAbsoluteUrl($originalUrl, $newUrl) -{ - $newScheme = parse_url($newUrl, PHP_URL_SCHEME); - // Already an absolute URL. - if (!empty($newScheme)) { - return $newUrl; - } - - $parts = parse_url($originalUrl); - $final = $parts['scheme'] .'://'. $parts['host']; - $final .= (!empty($parts['port'])) ? $parts['port'] : ''; - $final .= '/'; - if ($newUrl[0] != '/') { - $final .= substr(ltrim($parts['path'], '/'), 0, strrpos($parts['path'], '/')); - } - $final .= ltrim($newUrl, '/'); - return $final; -} - -/** - * Returns the server's base URL: scheme://domain.tld[:port] - * - * @param array $server the $_SERVER array - * - * @return string the server's base URL - * - * @see http://www.ietf.org/rfc/rfc7239.txt - * @see http://www.ietf.org/rfc/rfc6648.txt - * @see http://stackoverflow.com/a/3561399 - * @see http://stackoverflow.com/q/452375 - */ -function server_url($server) -{ - $scheme = 'http'; - $port = ''; - - // Shaarli is served behind a proxy - if (isset($server['HTTP_X_FORWARDED_PROTO'])) { - // Keep forwarded scheme - if (strpos($server['HTTP_X_FORWARDED_PROTO'], ',') !== false) { - $schemes = explode(',', $server['HTTP_X_FORWARDED_PROTO']); - $scheme = trim($schemes[0]); - } else { - $scheme = $server['HTTP_X_FORWARDED_PROTO']; - } - - if (isset($server['HTTP_X_FORWARDED_PORT'])) { - // Keep forwarded port - if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { - $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); - $port = trim($ports[0]); - } else { - $port = $server['HTTP_X_FORWARDED_PORT']; - } - - // This is a workaround for proxies that don't forward the scheme properly. - // Connecting over port 443 has to be in HTTPS. - // See https://github.com/shaarli/Shaarli/issues/1022 - if ($port == '443') { - $scheme = 'https'; - } - - if (($scheme == 'http' && $port != '80') - || ($scheme == 'https' && $port != '443') - ) { - $port = ':' . $port; - } else { - $port = ''; - } - } - - if (isset($server['HTTP_X_FORWARDED_HOST'])) { - // Keep forwarded host - if (strpos($server['HTTP_X_FORWARDED_HOST'], ',') !== false) { - $hosts = explode(',', $server['HTTP_X_FORWARDED_HOST']); - $host = trim($hosts[0]); - } else { - $host = $server['HTTP_X_FORWARDED_HOST']; - } - } else { - $host = $server['SERVER_NAME']; - } - - return $scheme.'://'.$host.$port; - } - - // SSL detection - if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') - || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { - $scheme = 'https'; - } - - // Do not append standard port values - if (($scheme == 'http' && $server['SERVER_PORT'] != '80') - || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { - $port = ':'.$server['SERVER_PORT']; - } - - return $scheme.'://'.$server['SERVER_NAME'].$port; -} - -/** - * Returns the absolute URL of the current script, without the query - * - * If the resource is "index.php", then it is removed (for better-looking URLs) - * - * @param array $server the $_SERVER array - * - * @return string the absolute URL of the current script, without the query - */ -function index_url($server) -{ - $scriptname = $server['SCRIPT_NAME']; - if (endsWith($scriptname, 'index.php')) { - $scriptname = substr($scriptname, 0, -9); - } - return server_url($server) . $scriptname; -} - -/** - * Returns the absolute URL of the current script, with the query - * - * If the resource is "index.php", then it is removed (for better-looking URLs) - * - * @param array $server the $_SERVER array - * - * @return string the absolute URL of the current script, with the query - */ -function page_url($server) -{ - if (! empty($server['QUERY_STRING'])) { - return index_url($server).'?'.$server['QUERY_STRING']; - } - return index_url($server); -} - -/** - * Retrieve the initial IP forwarded by the reverse proxy. - * - * Inspired from: https://github.com/zendframework/zend-http/blob/master/src/PhpEnvironment/RemoteAddress.php - * - * @param array $server $_SERVER array which contains HTTP headers. - * @param array $trustedIps List of trusted IP from the configuration. - * - * @return string|bool The forwarded IP, or false if none could be extracted. - */ -function getIpAddressFromProxy($server, $trustedIps) -{ - $forwardedIpHeader = 'HTTP_X_FORWARDED_FOR'; - if (empty($server[$forwardedIpHeader])) { - return false; - } - - $ips = preg_split('/\s*,\s*/', $server[$forwardedIpHeader]); - $ips = array_diff($ips, $trustedIps); - if (empty($ips)) { - return false; - } - - return array_pop($ips); -} - - -/** - * Return an identifier based on the advertised client IP address(es) - * - * This aims at preventing session hijacking from users behind the same proxy - * by relying on HTTP headers. - * - * See: - * - https://secure.php.net/manual/en/reserved.variables.server.php - * - https://stackoverflow.com/questions/3003145/how-to-get-the-client-ip-address-in-php - * - https://stackoverflow.com/questions/12233406/preventing-session-hijacking - * - https://stackoverflow.com/questions/21354859/trusting-x-forwarded-for-to-identify-a-visitor - * - * @param array $server The $_SERVER array - * - * @return string An identifier based on client IP address information - */ -function client_ip_id($server) -{ - $ip = $server['REMOTE_ADDR']; - - if (isset($server['HTTP_X_FORWARDED_FOR'])) { - $ip = $ip . '_' . $server['HTTP_X_FORWARDED_FOR']; - } - if (isset($server['HTTP_CLIENT_IP'])) { - $ip = $ip . '_' . $server['HTTP_CLIENT_IP']; - } - return $ip; -} - - -/** - * Returns true if Shaarli's currently browsed in HTTPS. - * Supports reverse proxies (if the headers are correctly set). - * - * @param array $server $_SERVER. - * - * @return bool true if HTTPS, false otherwise. - */ -function is_https($server) -{ - - if (isset($server['HTTP_X_FORWARDED_PORT'])) { - // Keep forwarded port - if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { - $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); - $port = trim($ports[0]); - } else { - $port = $server['HTTP_X_FORWARDED_PORT']; - } - - if ($port == '443') { - return true; - } - } - - return ! empty($server['HTTPS']); -} diff --git a/application/Url.php b/application/Url.php deleted file mode 100644 index 81f72fb0..00000000 --- a/application/Url.php +++ /dev/null @@ -1,88 +0,0 @@ -cleanup(); -} - -/** - * Get URL scheme. - * - * @param string url Url for which the scheme is requested - * - * @return mixed the URL scheme or false if none is provided. - */ -function get_url_scheme($url) -{ - $obj_url = new \Shaarli\Http\Url($url); - return $obj_url->getScheme(); -} - -/** - * Adds a trailing slash at the end of URL if necessary. - * - * @param string $url URL to check/edit. - * - * @return string $url URL with a end trailing slash. - */ -function add_trailing_slash($url) -{ - return $url . (!endsWith($url, '/') ? '/' : ''); -} - -/** - * Replace not whitelisted protocols by 'http://' from given URL. - * - * @param string $url URL to clean - * @param array $protocols List of allowed protocols (aside from http(s)). - * - * @return string URL with allowed protocol - */ -function whitelist_protocols($url, $protocols) -{ - if (startsWith($url, '?') || startsWith($url, '/')) { - return $url; - } - $protocols = array_merge(['http', 'https'], $protocols); - $protocol = preg_match('#^(\w+):/?/?#', $url, $match); - // Protocol not allowed: we remove it and replace it with http - if ($protocol === 1 && ! in_array($match[1], $protocols)) { - $url = str_replace($match[0], 'http://', $url); - } elseif ($protocol !== 1) { - $url = 'http://' . $url; - } - return $url; -} diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php new file mode 100644 index 00000000..2ea9195d --- /dev/null +++ b/application/http/HttpUtils.php @@ -0,0 +1,479 @@ +idnToAscii(); + + if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { + return array(array(0 => 'Invalid HTTP UrlUtils'), false); + } + + $userAgent = + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)' + . ' Gecko/20100101 Firefox/45.0'; + $acceptLanguage = + substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3'; + $maxRedirs = 3; + + if (!function_exists('curl_init')) { + return get_http_response_fallback( + $cleanUrl, + $timeout, + $maxBytes, + $userAgent, + $acceptLanguage, + $maxRedirs + ); + } + + $ch = curl_init($cleanUrl); + if ($ch === false) { + return array(array(0 => 'curl_init() error'), false); + } + + // General cURL settings + curl_setopt($ch, CURLOPT_AUTOREFERER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt( + $ch, + CURLOPT_HTTPHEADER, + array('Accept-Language: ' . $acceptLanguage) + ); + curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); + + if (is_callable($curlWriteFunction)) { + curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); + } + + // Max download size management + curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + curl_setopt( + $ch, + CURLOPT_PROGRESSFUNCTION, + function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { + if (version_compare(phpversion(), '5.5', '<')) { + // PHP version lower than 5.5 + // Callback has 4 arguments + $downloaded = $arg1; + } else { + // Callback has 5 arguments + $downloaded = $arg2; + } + // Non-zero return stops downloading + return ($downloaded > $maxBytes) ? 1 : 0; + } + ); + + $response = curl_exec($ch); + $errorNo = curl_errno($ch); + $errorStr = curl_error($ch); + $headSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + + if ($response === false) { + if ($errorNo == CURLE_COULDNT_RESOLVE_HOST) { + /* + * Workaround to match fallback method behaviour + * Removing this would require updating + * GetHttpUrlTest::testGetInvalidRemoteUrl() + */ + return array(false, false); + } + return array(array(0 => 'curl_exec() error: ' . $errorStr), false); + } + + // Formatting output like the fallback method + $rawHeaders = substr($response, 0, $headSize); + + // Keep only headers from latest redirection + $rawHeadersArrayRedirs = explode("\r\n\r\n", trim($rawHeaders)); + $rawHeadersLastRedir = end($rawHeadersArrayRedirs); + + $content = substr($response, $headSize); + $headers = array(); + foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { + if (empty($line) || ctype_space($line)) { + continue; + } + $splitLine = explode(': ', $line, 2); + if (count($splitLine) > 1) { + $key = $splitLine[0]; + $value = $splitLine[1]; + if (array_key_exists($key, $headers)) { + if (!is_array($headers[$key])) { + $headers[$key] = array(0 => $headers[$key]); + } + $headers[$key][] = $value; + } else { + $headers[$key] = $value; + } + } else { + $headers[] = $splitLine[0]; + } + } + + return array($headers, $content); +} + +/** + * GET an HTTP URL to retrieve its content (fallback method) + * + * @param string $cleanUrl URL to get (http://... valid and in ASCII form) + * @param int $timeout network timeout (in seconds) + * @param int $maxBytes maximum downloaded bytes + * @param string $userAgent "User-Agent" header + * @param string $acceptLanguage "Accept-Language" header + * @param int $maxRedr maximum amount of redirections followed + * + * @return array HTTP response headers, downloaded content + * + * Output format: + * [0] = associative array containing HTTP response headers + * [1] = URL content (downloaded data) + * + * @see http://php.net/manual/en/function.file-get-contents.php + * @see http://php.net/manual/en/function.stream-context-create.php + * @see http://php.net/manual/en/function.get-headers.php + */ +function get_http_response_fallback( + $cleanUrl, + $timeout, + $maxBytes, + $userAgent, + $acceptLanguage, + $maxRedr +) { + $options = array( + 'http' => array( + 'method' => 'GET', + 'timeout' => $timeout, + 'user_agent' => $userAgent, + 'header' => "Accept: */*\r\n" + . 'Accept-Language: ' . $acceptLanguage + ) + ); + + stream_context_set_default($options); + list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); + if (! $headers || strpos($headers[0], '200 OK') === false) { + $options['http']['request_fulluri'] = true; + stream_context_set_default($options); + list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); + } + + if (! $headers) { + return array($headers, false); + } + + try { + // TODO: catch Exception in calling code (thumbnailer) + $context = stream_context_create($options); + $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); + } catch (Exception $exc) { + return array(array(0 => 'HTTP Error'), $exc->getMessage()); + } + + return array($headers, $content); +} + +/** + * Retrieve HTTP headers, following n redirections (temporary and permanent ones). + * + * @param string $url initial URL to reach. + * @param int $redirectionLimit max redirection follow. + * + * @return array HTTP headers, or false if it failed. + */ +function get_redirected_headers($url, $redirectionLimit = 3) +{ + $headers = get_headers($url, 1); + if (!empty($headers['location']) && empty($headers['Location'])) { + $headers['Location'] = $headers['location']; + } + + // Headers found, redirection found, and limit not reached. + if ($redirectionLimit-- > 0 + && !empty($headers) + && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) + && !empty($headers['Location'])) { + $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; + if ($redirection != $url) { + $redirection = getAbsoluteUrl($url, $redirection); + return get_redirected_headers($redirection, $redirectionLimit); + } + } + + return array($headers, $url); +} + +/** + * Get an absolute URL from a complete one, and another absolute/relative URL. + * + * @param string $originalUrl The original complete URL. + * @param string $newUrl The new one, absolute or relative. + * + * @return string Final URL: + * - $newUrl if it was already an absolute URL. + * - if it was relative, absolute URL from $originalUrl path. + */ +function getAbsoluteUrl($originalUrl, $newUrl) +{ + $newScheme = parse_url($newUrl, PHP_URL_SCHEME); + // Already an absolute URL. + if (!empty($newScheme)) { + return $newUrl; + } + + $parts = parse_url($originalUrl); + $final = $parts['scheme'] .'://'. $parts['host']; + $final .= (!empty($parts['port'])) ? $parts['port'] : ''; + $final .= '/'; + if ($newUrl[0] != '/') { + $final .= substr(ltrim($parts['path'], '/'), 0, strrpos($parts['path'], '/')); + } + $final .= ltrim($newUrl, '/'); + return $final; +} + +/** + * Returns the server's base URL: scheme://domain.tld[:port] + * + * @param array $server the $_SERVER array + * + * @return string the server's base URL + * + * @see http://www.ietf.org/rfc/rfc7239.txt + * @see http://www.ietf.org/rfc/rfc6648.txt + * @see http://stackoverflow.com/a/3561399 + * @see http://stackoverflow.com/q/452375 + */ +function server_url($server) +{ + $scheme = 'http'; + $port = ''; + + // Shaarli is served behind a proxy + if (isset($server['HTTP_X_FORWARDED_PROTO'])) { + // Keep forwarded scheme + if (strpos($server['HTTP_X_FORWARDED_PROTO'], ',') !== false) { + $schemes = explode(',', $server['HTTP_X_FORWARDED_PROTO']); + $scheme = trim($schemes[0]); + } else { + $scheme = $server['HTTP_X_FORWARDED_PROTO']; + } + + if (isset($server['HTTP_X_FORWARDED_PORT'])) { + // Keep forwarded port + if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { + $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); + $port = trim($ports[0]); + } else { + $port = $server['HTTP_X_FORWARDED_PORT']; + } + + // This is a workaround for proxies that don't forward the scheme properly. + // Connecting over port 443 has to be in HTTPS. + // See https://github.com/shaarli/Shaarli/issues/1022 + if ($port == '443') { + $scheme = 'https'; + } + + if (($scheme == 'http' && $port != '80') + || ($scheme == 'https' && $port != '443') + ) { + $port = ':' . $port; + } else { + $port = ''; + } + } + + if (isset($server['HTTP_X_FORWARDED_HOST'])) { + // Keep forwarded host + if (strpos($server['HTTP_X_FORWARDED_HOST'], ',') !== false) { + $hosts = explode(',', $server['HTTP_X_FORWARDED_HOST']); + $host = trim($hosts[0]); + } else { + $host = $server['HTTP_X_FORWARDED_HOST']; + } + } else { + $host = $server['SERVER_NAME']; + } + + return $scheme.'://'.$host.$port; + } + + // SSL detection + if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') + || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { + $scheme = 'https'; + } + + // Do not append standard port values + if (($scheme == 'http' && $server['SERVER_PORT'] != '80') + || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { + $port = ':'.$server['SERVER_PORT']; + } + + return $scheme.'://'.$server['SERVER_NAME'].$port; +} + +/** + * Returns the absolute URL of the current script, without the query + * + * If the resource is "index.php", then it is removed (for better-looking URLs) + * + * @param array $server the $_SERVER array + * + * @return string the absolute URL of the current script, without the query + */ +function index_url($server) +{ + $scriptname = $server['SCRIPT_NAME']; + if (endsWith($scriptname, 'index.php')) { + $scriptname = substr($scriptname, 0, -9); + } + return server_url($server) . $scriptname; +} + +/** + * Returns the absolute URL of the current script, with the query + * + * If the resource is "index.php", then it is removed (for better-looking URLs) + * + * @param array $server the $_SERVER array + * + * @return string the absolute URL of the current script, with the query + */ +function page_url($server) +{ + if (! empty($server['QUERY_STRING'])) { + return index_url($server).'?'.$server['QUERY_STRING']; + } + return index_url($server); +} + +/** + * Retrieve the initial IP forwarded by the reverse proxy. + * + * Inspired from: https://github.com/zendframework/zend-http/blob/master/src/PhpEnvironment/RemoteAddress.php + * + * @param array $server $_SERVER array which contains HTTP headers. + * @param array $trustedIps List of trusted IP from the configuration. + * + * @return string|bool The forwarded IP, or false if none could be extracted. + */ +function getIpAddressFromProxy($server, $trustedIps) +{ + $forwardedIpHeader = 'HTTP_X_FORWARDED_FOR'; + if (empty($server[$forwardedIpHeader])) { + return false; + } + + $ips = preg_split('/\s*,\s*/', $server[$forwardedIpHeader]); + $ips = array_diff($ips, $trustedIps); + if (empty($ips)) { + return false; + } + + return array_pop($ips); +} + + +/** + * Return an identifier based on the advertised client IP address(es) + * + * This aims at preventing session hijacking from users behind the same proxy + * by relying on HTTP headers. + * + * See: + * - https://secure.php.net/manual/en/reserved.variables.server.php + * - https://stackoverflow.com/questions/3003145/how-to-get-the-client-ip-address-in-php + * - https://stackoverflow.com/questions/12233406/preventing-session-hijacking + * - https://stackoverflow.com/questions/21354859/trusting-x-forwarded-for-to-identify-a-visitor + * + * @param array $server The $_SERVER array + * + * @return string An identifier based on client IP address information + */ +function client_ip_id($server) +{ + $ip = $server['REMOTE_ADDR']; + + if (isset($server['HTTP_X_FORWARDED_FOR'])) { + $ip = $ip . '_' . $server['HTTP_X_FORWARDED_FOR']; + } + if (isset($server['HTTP_CLIENT_IP'])) { + $ip = $ip . '_' . $server['HTTP_CLIENT_IP']; + } + return $ip; +} + + +/** + * Returns true if Shaarli's currently browsed in HTTPS. + * Supports reverse proxies (if the headers are correctly set). + * + * @param array $server $_SERVER. + * + * @return bool true if HTTPS, false otherwise. + */ +function is_https($server) +{ + + if (isset($server['HTTP_X_FORWARDED_PORT'])) { + // Keep forwarded port + if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) { + $ports = explode(',', $server['HTTP_X_FORWARDED_PORT']); + $port = trim($ports[0]); + } else { + $port = $server['HTTP_X_FORWARDED_PORT']; + } + + if ($port == '443') { + return true; + } + } + + return ! empty($server['HTTPS']); +} diff --git a/application/http/Url.php b/application/http/Url.php index 260231c6..90444a2f 100644 --- a/application/http/Url.php +++ b/application/http/Url.php @@ -206,7 +206,7 @@ class Url } /** - * Test if the Url is an HTTP one. + * Test if the UrlUtils is an HTTP one. * * @return true is HTTP, false otherwise. */ diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php new file mode 100644 index 00000000..4bc84b82 --- /dev/null +++ b/application/http/UrlUtils.php @@ -0,0 +1,88 @@ +cleanup(); +} + +/** + * Get URL scheme. + * + * @param string url UrlUtils for which the scheme is requested + * + * @return mixed the URL scheme or false if none is provided. + */ +function get_url_scheme($url) +{ + $obj_url = new \Shaarli\Http\Url($url); + return $obj_url->getScheme(); +} + +/** + * Adds a trailing slash at the end of URL if necessary. + * + * @param string $url URL to check/edit. + * + * @return string $url URL with a end trailing slash. + */ +function add_trailing_slash($url) +{ + return $url . (!endsWith($url, '/') ? '/' : ''); +} + +/** + * Replace not whitelisted protocols by 'http://' from given URL. + * + * @param string $url URL to clean + * @param array $protocols List of allowed protocols (aside from http(s)). + * + * @return string URL with allowed protocol + */ +function whitelist_protocols($url, $protocols) +{ + if (startsWith($url, '?') || startsWith($url, '/')) { + return $url; + } + $protocols = array_merge(['http', 'https'], $protocols); + $protocol = preg_match('#^(\w+):/?/?#', $url, $match); + // Protocol not allowed: we remove it and replace it with http + if ($protocol === 1 && ! in_array($match[1], $protocols)) { + $url = str_replace($match[0], 'http://', $url); + } elseif ($protocol !== 1) { + $url = 'http://' . $url; + } + return $url; +} diff --git a/index.php b/index.php index 6d1ae3fc..66fe30f1 100644 --- a/index.php +++ b/index.php @@ -59,16 +59,16 @@ require_once __DIR__ . '/vendor/autoload.php'; require_once 'application/ApplicationUtils.php'; require_once 'application/config/ConfigPlugin.php'; require_once 'application/feed/Cache.php'; +require_once 'application/http/HttpUtils.php'; +require_once 'application/http/UrlUtils.php'; require_once 'application/FileUtils.php'; require_once 'application/History.php'; -require_once 'application/HttpUtils.php'; require_once 'application/LinkDB.php'; require_once 'application/LinkFilter.php'; require_once 'application/LinkUtils.php'; require_once 'application/NetscapeBookmarkUtils.php'; require_once 'application/PageBuilder.php'; require_once 'application/TimeZone.php'; -require_once 'application/Url.php'; require_once 'application/Utils.php'; require_once 'application/PluginManager.php'; require_once 'application/Router.php'; diff --git a/tests/HttpUtils/ClientIpIdTest.php b/tests/HttpUtils/ClientIpIdTest.php deleted file mode 100644 index c15ac5cc..00000000 --- a/tests/HttpUtils/ClientIpIdTest.php +++ /dev/null @@ -1,52 +0,0 @@ -assertEquals( - '10.1.167.42', - client_ip_id(['REMOTE_ADDR' => '10.1.167.42']) - ); - } - - /** - * Get a remote client ID based on its IP and proxy information (1) - */ - public function testClientIpIdRemoteForwarded() - { - $this->assertEquals( - '10.1.167.42_127.0.1.47', - client_ip_id([ - 'REMOTE_ADDR' => '10.1.167.42', - 'HTTP_X_FORWARDED_FOR' => '127.0.1.47' - ]) - ); - } - - /** - * Get a remote client ID based on its IP and proxy information (2) - */ - public function testClientIpIdRemoteForwardedClient() - { - $this->assertEquals( - '10.1.167.42_10.1.167.56_127.0.1.47', - client_ip_id([ - 'REMOTE_ADDR' => '10.1.167.42', - 'HTTP_X_FORWARDED_FOR' => '10.1.167.56', - 'HTTP_CLIENT_IP' => '127.0.1.47' - ]) - ); - } -} diff --git a/tests/HttpUtils/GetHttpUrlTest.php b/tests/HttpUtils/GetHttpUrlTest.php deleted file mode 100644 index ea53de5f..00000000 --- a/tests/HttpUtils/GetHttpUrlTest.php +++ /dev/null @@ -1,65 +0,0 @@ -assertEquals('Invalid HTTP Url', $headers[0]); - $this->assertFalse($content); - - // Non HTTP - list($headers, $content) = get_http_response('ftp://save.tld/mysave', 1); - $this->assertEquals('Invalid HTTP Url', $headers[0]); - $this->assertFalse($content); - } - - /** - * Get an invalid remote URL - */ - public function testGetInvalidRemoteUrl() - { - list($headers, $content) = @get_http_response('http://non.existent', 1); - $this->assertFalse($headers); - $this->assertFalse($content); - } - - /** - * Test getAbsoluteUrl with relative target URL. - */ - public function testGetAbsoluteUrlWithRelative() - { - $origin = 'http://non.existent/blabla/?test'; - $target = '/stuff.php'; - - $expected = 'http://non.existent/stuff.php'; - $this->assertEquals($expected, getAbsoluteUrl($origin, $target)); - - $target = 'stuff.php'; - $expected = 'http://non.existent/blabla/stuff.php'; - $this->assertEquals($expected, getAbsoluteUrl($origin, $target)); - } - - /** - * Test getAbsoluteUrl with absolute target URL. - */ - public function testGetAbsoluteUrlWithAbsolute() - { - $origin = 'http://non.existent/blabla/?test'; - $target = 'http://other.url/stuff.php'; - - $this->assertEquals($target, getAbsoluteUrl($origin, $target)); - } -} diff --git a/tests/HttpUtils/GetIpAdressFromProxyTest.php b/tests/HttpUtils/GetIpAdressFromProxyTest.php deleted file mode 100644 index 7af5bd9d..00000000 --- a/tests/HttpUtils/GetIpAdressFromProxyTest.php +++ /dev/null @@ -1,59 +0,0 @@ -assertFalse(getIpAddressFromProxy(array(), array())); - } - - /** - * Test with a single IP in proxy header. - */ - public function testWithOneForwardedIp() - { - $ip = '1.1.1.1'; - $server = array('HTTP_X_FORWARDED_FOR' => $ip); - $this->assertEquals($ip, getIpAddressFromProxy($server, array())); - } - - /** - * Test with a multiple IPs in proxy header. - */ - public function testWithMultipleForwardedIp() - { - $ip = '1.1.1.1'; - $ip2 = '2.2.2.2'; - - $server = array('HTTP_X_FORWARDED_FOR' => $ip .','. $ip2); - $this->assertEquals($ip2, getIpAddressFromProxy($server, array())); - - $server = array('HTTP_X_FORWARDED_FOR' => $ip .' , '. $ip2); - $this->assertEquals($ip2, getIpAddressFromProxy($server, array())); - } - - /** - * Test with a trusted IP address. - */ - public function testWithTrustedIp() - { - $ip = '1.1.1.1'; - $ip2 = '2.2.2.2'; - - $server = array('HTTP_X_FORWARDED_FOR' => $ip); - $this->assertFalse(getIpAddressFromProxy($server, array($ip))); - - $server = array('HTTP_X_FORWARDED_FOR' => $ip .','. $ip2); - $this->assertEquals($ip2, getIpAddressFromProxy($server, array($ip))); - $this->assertFalse(getIpAddressFromProxy($server, array($ip, $ip2))); - } -} diff --git a/tests/HttpUtils/IndexUrlTest.php b/tests/HttpUtils/IndexUrlTest.php deleted file mode 100644 index 337dcab0..00000000 --- a/tests/HttpUtils/IndexUrlTest.php +++ /dev/null @@ -1,72 +0,0 @@ -assertEquals( - 'http://host.tld/', - index_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/index.php' - ) - ) - ); - - $this->assertEquals( - 'http://host.tld/admin/', - index_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/admin/index.php' - ) - ) - ); - } - - /** - * The resource is != "index.php" - */ - public function testOtherResource() - { - $this->assertEquals( - 'http://host.tld/page.php', - page_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/page.php' - ) - ) - ); - - $this->assertEquals( - 'http://host.tld/admin/page.php', - page_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/admin/page.php' - ) - ) - ); - } -} diff --git a/tests/HttpUtils/IsHttpsTest.php b/tests/HttpUtils/IsHttpsTest.php deleted file mode 100644 index 097f2bcf..00000000 --- a/tests/HttpUtils/IsHttpsTest.php +++ /dev/null @@ -1,36 +0,0 @@ -assertTrue(is_https(['HTTPS' => true])); - $this->assertTrue(is_https(['HTTPS' => '1'])); - $this->assertTrue(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => 443])); - $this->assertTrue(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => '443'])); - $this->assertTrue(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => '443,123,456,'])); - } - - /** - * Test is_https with HTTP values. - */ - public function testIsHttpsFalse() - { - $this->assertFalse(is_https([])); - $this->assertFalse(is_https(['HTTPS' => false])); - $this->assertFalse(is_https(['HTTPS' => '0'])); - $this->assertFalse(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => 123])); - $this->assertFalse(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => '123'])); - $this->assertFalse(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => ',123,456,'])); - } -} diff --git a/tests/HttpUtils/PageUrlTest.php b/tests/HttpUtils/PageUrlTest.php deleted file mode 100644 index 4dbbe9cf..00000000 --- a/tests/HttpUtils/PageUrlTest.php +++ /dev/null @@ -1,76 +0,0 @@ -assertEquals( - 'http://host.tld/?p1=v1&p2=v2', - page_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/index.php', - 'QUERY_STRING' => 'p1=v1&p2=v2' - ) - ) - ); - - $this->assertEquals( - 'http://host.tld/admin/?action=edit_tag', - page_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/admin/index.php', - 'QUERY_STRING' => 'action=edit_tag' - ) - ) - ); - } - - /** - * The resource is != "index.php" - */ - public function testOtherResource() - { - $this->assertEquals( - 'http://host.tld/page.php?p1=v1&p2=v2', - page_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/page.php', - 'QUERY_STRING' => 'p1=v1&p2=v2' - ) - ) - ); - - $this->assertEquals( - 'http://host.tld/admin/page.php?action=edit_tag', - page_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'SCRIPT_NAME' => '/admin/page.php', - 'QUERY_STRING' => 'action=edit_tag' - ) - ) - ); - } -} diff --git a/tests/HttpUtils/ServerUrlTest.php b/tests/HttpUtils/ServerUrlTest.php deleted file mode 100644 index 324b827a..00000000 --- a/tests/HttpUtils/ServerUrlTest.php +++ /dev/null @@ -1,221 +0,0 @@ -assertEquals( - 'https://host.tld', - server_url( - array( - 'HTTPS' => 'ON', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '443' - ) - ) - ); - - $this->assertEquals( - 'https://host.tld:8080', - server_url( - array( - 'HTTPS' => 'ON', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '8080' - ) - ) - ); - } - - /** - * Detect a Proxy that sets Forwarded-Host - */ - public function testHttpsProxyForwardedHost() - { - $this->assertEquals( - 'https://host.tld:8080', - server_url( - array( - 'HTTP_X_FORWARDED_PROTO' => 'https', - 'HTTP_X_FORWARDED_PORT' => '8080', - 'HTTP_X_FORWARDED_HOST' => 'host.tld' - ) - ) - ); - - $this->assertEquals( - 'https://host.tld:4974', - server_url( - array( - 'HTTP_X_FORWARDED_PROTO' => 'https, https', - 'HTTP_X_FORWARDED_PORT' => '4974, 80', - 'HTTP_X_FORWARDED_HOST' => 'host.tld, example.com' - ) - ) - ); - } - - /** - * Detect a Proxy with SSL enabled - */ - public function testHttpsProxyForward() - { - $this->assertEquals( - 'https://host.tld:8080', - server_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'HTTP_X_FORWARDED_PROTO' => 'https', - 'HTTP_X_FORWARDED_PORT' => '8080' - ) - ) - ); - - $this->assertEquals( - 'https://host.tld', - server_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'HTTP_X_FORWARDED_PROTO' => 'https' - ) - ) - ); - - $this->assertEquals( - 'https://host.tld', - server_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'HTTP_X_FORWARDED_PROTO' => 'https', - 'HTTP_X_FORWARDED_PORT' => '443' - ) - ) - ); - - $this->assertEquals( - 'https://host.tld:4974', - server_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'HTTP_X_FORWARDED_PROTO' => 'https, https', - 'HTTP_X_FORWARDED_PORT' => '4974, 80' - ) - ) - ); - } - - /** - * Detect if the server uses a specific port (!= 80) - */ - public function testPort() - { - // HTTP - $this->assertEquals( - 'http://host.tld:8080', - server_url( - array( - 'HTTPS' => 'OFF', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '8080' - ) - ) - ); - - // HTTPS - $this->assertEquals( - 'https://host.tld:8080', - server_url( - array( - 'HTTPS' => 'ON', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '8080' - ) - ) - ); - } - - /** - * HTTP server on port 80 - */ - public function testStandardHttpPort() - { - $this->assertEquals( - 'http://host.tld', - server_url( - array( - 'HTTPS' => 'OFF', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80' - ) - ) - ); - } - - /** - * HTTPS server on port 443 - */ - public function testStandardHttpsPort() - { - $this->assertEquals( - 'https://host.tld', - server_url( - array( - 'HTTPS' => 'ON', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '443' - ) - ) - ); - } - - /** - * Misconfigured server (see #1022): Proxy HTTP but 443 - */ - public function testHttpWithPort433() - { - $this->assertEquals( - 'https://host.tld', - server_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'HTTP_X_FORWARDED_PROTO' => 'http', - 'HTTP_X_FORWARDED_PORT' => '443' - ) - ) - ); - - $this->assertEquals( - 'https://host.tld', - server_url( - array( - 'HTTPS' => 'Off', - 'SERVER_NAME' => 'host.tld', - 'SERVER_PORT' => '80', - 'HTTP_X_FORWARDED_PROTO' => 'https, http', - 'HTTP_X_FORWARDED_PORT' => '443, 80' - ) - ) - ); - } -} diff --git a/tests/Url/CleanupUrlTest.php b/tests/Url/CleanupUrlTest.php deleted file mode 100644 index 24791948..00000000 --- a/tests/Url/CleanupUrlTest.php +++ /dev/null @@ -1,109 +0,0 @@ -assertEquals('', cleanup_url('')); - } - - /** - * Clean an already cleaned URL - */ - public function testCleanupUrlAlreadyClean() - { - $this->assertEquals($this->ref, cleanup_url($this->ref)); - $this->ref2 = $this->ref.'/path/to/dir/'; - $this->assertEquals($this->ref2, cleanup_url($this->ref2)); - } - - /** - * Clean URL fragments - */ - public function testCleanupUrlFragment() - { - $this->assertEquals($this->ref, cleanup_url($this->ref.'#tk.rss_all')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-U3ht0tkc4b')); - } - - /** - * Clean URL query - single annoying parameter - */ - public function testCleanupUrlQuerySingle() - { - $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_object_map=junk')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_ref_map=Cr4p!')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_type_map=g4R84g3')); - - $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb_stuff=v41u3')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb=71m3w4573')); - - $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_campaign=zomg')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_medium=numnum')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_source=c0d3')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_term=1n4l')); - - $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url')); - - $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_name=junk')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_start=junk')); - $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_item_index=junk')); - } - - /** - * Clean URL query - multiple annoying parameters - */ - public function testCleanupUrlQueryMultiple() - { - $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url&fb=som3th1ng')); - - $this->assertEquals($this->ref, cleanup_url( - $this->ref.'?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3' - )); - - $this->assertEquals($this->ref, cleanup_url( - $this->ref.'?campaign_start=zomg&campaign_name=numnum' - )); - } - - /** - * Clean URL query - multiple annoying parameters and fragment - */ - public function testCleanupUrlQueryFragment() - { - $this->assertEquals($this->ref, cleanup_url( - $this->ref.'?xtor=some-url&fb=som3th1ng#tk.rss_all' - )); - - // ditch annoying query params and fragment, keep useful params - $this->assertEquals( - $this->ref.'?my=stuff&is=kept', - cleanup_url( - $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all' - ) - ); - - // ditch annoying query params, keep useful params and fragment - $this->assertEquals( - $this->ref.'?my=stuff&is=kept#again', - cleanup_url( - $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again' - ) - ); - } -} diff --git a/tests/Url/GetUrlSchemeTest.php b/tests/Url/GetUrlSchemeTest.php deleted file mode 100644 index 18b932d6..00000000 --- a/tests/Url/GetUrlSchemeTest.php +++ /dev/null @@ -1,30 +0,0 @@ -assertEquals('', get_url_scheme('')); - } - - /** - * Get normal scheme of Url - */ - public function testGetUrlScheme() - { - $this->assertEquals('http', get_url_scheme('http://domain.tld:3000')); - $this->assertEquals('https', get_url_scheme('https://domain.tld:3000')); - $this->assertEquals('http', get_url_scheme('domain.tld')); - $this->assertEquals('ssh', get_url_scheme('ssh://domain.tld')); - $this->assertEquals('ftp', get_url_scheme('ftp://domain.tld')); - $this->assertEquals('git', get_url_scheme('git://domain.tld/push?pull=clone#checkout')); - } -} diff --git a/tests/Url/UnparseUrlTest.php b/tests/Url/UnparseUrlTest.php deleted file mode 100644 index e314b484..00000000 --- a/tests/Url/UnparseUrlTest.php +++ /dev/null @@ -1,30 +0,0 @@ -assertEquals('', unparse_url(array())); - } - - /** - * Rebuild a full-featured URL - */ - public function testUnparseFull() - { - $ref = 'http://username:password@hostname:9090/path' - .'?arg1=value1&arg2=value2#anchor'; - $this->assertEquals($ref, unparse_url(parse_url($ref))); - } -} diff --git a/tests/Url/WhitelistProtocolsTest.php b/tests/Url/WhitelistProtocolsTest.php deleted file mode 100644 index a3156804..00000000 --- a/tests/Url/WhitelistProtocolsTest.php +++ /dev/null @@ -1,63 +0,0 @@ -assertEquals($url, whitelist_protocols($url, $whitelist)); - $url = '/path.jpg'; - $this->assertEquals($url, whitelist_protocols($url, $whitelist)); - } - - /** - * Test whitelist_protocols() on a note (relative URL). - */ - public function testWhitelistProtocolMissing() - { - $whitelist = ['ftp', 'magnet']; - $url = 'test.tld/path/?query=value#hash'; - $this->assertEquals('http://'. $url, whitelist_protocols($url, $whitelist)); - } - - /** - * Test whitelist_protocols() with allowed protocols. - */ - public function testWhitelistAllowedProtocol() - { - $whitelist = ['ftp', 'magnet']; - $url = 'http://test.tld/path/?query=value#hash'; - $this->assertEquals($url, whitelist_protocols($url, $whitelist)); - $url = 'https://test.tld/path/?query=value#hash'; - $this->assertEquals($url, whitelist_protocols($url, $whitelist)); - $url = 'ftp://test.tld/path/?query=value#hash'; - $this->assertEquals($url, whitelist_protocols($url, $whitelist)); - $url = 'magnet:test.tld/path/?query=value#hash'; - $this->assertEquals($url, whitelist_protocols($url, $whitelist)); - } - - /** - * Test whitelist_protocols() with allowed protocols. - */ - public function testWhitelistDisallowedProtocol() - { - $whitelist = ['ftp', 'magnet']; - $url = 'javascript:alert("xss");'; - $this->assertEquals('http://alert("xss");', whitelist_protocols($url, $whitelist)); - $url = 'other://test.tld/path/?query=value#hash'; - $this->assertEquals('http://test.tld/path/?query=value#hash', whitelist_protocols($url, $whitelist)); - } -} diff --git a/tests/http/HttpUtils/ClientIpIdTest.php b/tests/http/HttpUtils/ClientIpIdTest.php new file mode 100644 index 00000000..982e57e0 --- /dev/null +++ b/tests/http/HttpUtils/ClientIpIdTest.php @@ -0,0 +1,54 @@ +assertEquals( + '10.1.167.42', + client_ip_id(['REMOTE_ADDR' => '10.1.167.42']) + ); + } + + /** + * Get a remote client ID based on its IP and proxy information (1) + */ + public function testClientIpIdRemoteForwarded() + { + $this->assertEquals( + '10.1.167.42_127.0.1.47', + client_ip_id([ + 'REMOTE_ADDR' => '10.1.167.42', + 'HTTP_X_FORWARDED_FOR' => '127.0.1.47' + ]) + ); + } + + /** + * Get a remote client ID based on its IP and proxy information (2) + */ + public function testClientIpIdRemoteForwardedClient() + { + $this->assertEquals( + '10.1.167.42_10.1.167.56_127.0.1.47', + client_ip_id([ + 'REMOTE_ADDR' => '10.1.167.42', + 'HTTP_X_FORWARDED_FOR' => '10.1.167.56', + 'HTTP_CLIENT_IP' => '127.0.1.47' + ]) + ); + } +} diff --git a/tests/http/HttpUtils/GetHttpUrlTest.php b/tests/http/HttpUtils/GetHttpUrlTest.php new file mode 100644 index 00000000..3dc5bc9b --- /dev/null +++ b/tests/http/HttpUtils/GetHttpUrlTest.php @@ -0,0 +1,67 @@ +assertEquals('Invalid HTTP UrlUtils', $headers[0]); + $this->assertFalse($content); + + // Non HTTP + list($headers, $content) = get_http_response('ftp://save.tld/mysave', 1); + $this->assertEquals('Invalid HTTP UrlUtils', $headers[0]); + $this->assertFalse($content); + } + + /** + * Get an invalid remote URL + */ + public function testGetInvalidRemoteUrl() + { + list($headers, $content) = @get_http_response('http://non.existent', 1); + $this->assertFalse($headers); + $this->assertFalse($content); + } + + /** + * Test getAbsoluteUrl with relative target URL. + */ + public function testGetAbsoluteUrlWithRelative() + { + $origin = 'http://non.existent/blabla/?test'; + $target = '/stuff.php'; + + $expected = 'http://non.existent/stuff.php'; + $this->assertEquals($expected, getAbsoluteUrl($origin, $target)); + + $target = 'stuff.php'; + $expected = 'http://non.existent/blabla/stuff.php'; + $this->assertEquals($expected, getAbsoluteUrl($origin, $target)); + } + + /** + * Test getAbsoluteUrl with absolute target URL. + */ + public function testGetAbsoluteUrlWithAbsolute() + { + $origin = 'http://non.existent/blabla/?test'; + $target = 'http://other.url/stuff.php'; + + $this->assertEquals($target, getAbsoluteUrl($origin, $target)); + } +} diff --git a/tests/http/HttpUtils/GetIpAdressFromProxyTest.php b/tests/http/HttpUtils/GetIpAdressFromProxyTest.php new file mode 100644 index 00000000..fe3a639e --- /dev/null +++ b/tests/http/HttpUtils/GetIpAdressFromProxyTest.php @@ -0,0 +1,61 @@ +assertFalse(getIpAddressFromProxy(array(), array())); + } + + /** + * Test with a single IP in proxy header. + */ + public function testWithOneForwardedIp() + { + $ip = '1.1.1.1'; + $server = array('HTTP_X_FORWARDED_FOR' => $ip); + $this->assertEquals($ip, getIpAddressFromProxy($server, array())); + } + + /** + * Test with a multiple IPs in proxy header. + */ + public function testWithMultipleForwardedIp() + { + $ip = '1.1.1.1'; + $ip2 = '2.2.2.2'; + + $server = array('HTTP_X_FORWARDED_FOR' => $ip .','. $ip2); + $this->assertEquals($ip2, getIpAddressFromProxy($server, array())); + + $server = array('HTTP_X_FORWARDED_FOR' => $ip .' , '. $ip2); + $this->assertEquals($ip2, getIpAddressFromProxy($server, array())); + } + + /** + * Test with a trusted IP address. + */ + public function testWithTrustedIp() + { + $ip = '1.1.1.1'; + $ip2 = '2.2.2.2'; + + $server = array('HTTP_X_FORWARDED_FOR' => $ip); + $this->assertFalse(getIpAddressFromProxy($server, array($ip))); + + $server = array('HTTP_X_FORWARDED_FOR' => $ip .','. $ip2); + $this->assertEquals($ip2, getIpAddressFromProxy($server, array($ip))); + $this->assertFalse(getIpAddressFromProxy($server, array($ip, $ip2))); + } +} diff --git a/tests/http/HttpUtils/IndexUrlTest.php b/tests/http/HttpUtils/IndexUrlTest.php new file mode 100644 index 00000000..bcbe59cb --- /dev/null +++ b/tests/http/HttpUtils/IndexUrlTest.php @@ -0,0 +1,74 @@ +assertEquals( + 'http://host.tld/', + index_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/index.php' + ) + ) + ); + + $this->assertEquals( + 'http://host.tld/admin/', + index_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/admin/index.php' + ) + ) + ); + } + + /** + * The resource is != "index.php" + */ + public function testOtherResource() + { + $this->assertEquals( + 'http://host.tld/page.php', + page_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/page.php' + ) + ) + ); + + $this->assertEquals( + 'http://host.tld/admin/page.php', + page_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/admin/page.php' + ) + ) + ); + } +} diff --git a/tests/http/HttpUtils/IsHttpsTest.php b/tests/http/HttpUtils/IsHttpsTest.php new file mode 100644 index 00000000..348956c6 --- /dev/null +++ b/tests/http/HttpUtils/IsHttpsTest.php @@ -0,0 +1,39 @@ +assertTrue(is_https(['HTTPS' => true])); + $this->assertTrue(is_https(['HTTPS' => '1'])); + $this->assertTrue(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => 443])); + $this->assertTrue(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => '443'])); + $this->assertTrue(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => '443,123,456,'])); + } + + /** + * Test is_https with HTTP values. + */ + public function testIsHttpsFalse() + { + $this->assertFalse(is_https([])); + $this->assertFalse(is_https(['HTTPS' => false])); + $this->assertFalse(is_https(['HTTPS' => '0'])); + $this->assertFalse(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => 123])); + $this->assertFalse(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => '123'])); + $this->assertFalse(is_https(['HTTPS' => false, 'HTTP_X_FORWARDED_PORT' => ',123,456,'])); + } +} diff --git a/tests/http/HttpUtils/PageUrlTest.php b/tests/http/HttpUtils/PageUrlTest.php new file mode 100644 index 00000000..f1991716 --- /dev/null +++ b/tests/http/HttpUtils/PageUrlTest.php @@ -0,0 +1,78 @@ +assertEquals( + 'http://host.tld/?p1=v1&p2=v2', + page_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/index.php', + 'QUERY_STRING' => 'p1=v1&p2=v2' + ) + ) + ); + + $this->assertEquals( + 'http://host.tld/admin/?action=edit_tag', + page_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/admin/index.php', + 'QUERY_STRING' => 'action=edit_tag' + ) + ) + ); + } + + /** + * The resource is != "index.php" + */ + public function testOtherResource() + { + $this->assertEquals( + 'http://host.tld/page.php?p1=v1&p2=v2', + page_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/page.php', + 'QUERY_STRING' => 'p1=v1&p2=v2' + ) + ) + ); + + $this->assertEquals( + 'http://host.tld/admin/page.php?action=edit_tag', + page_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'SCRIPT_NAME' => '/admin/page.php', + 'QUERY_STRING' => 'action=edit_tag' + ) + ) + ); + } +} diff --git a/tests/http/HttpUtils/ServerUrlTest.php b/tests/http/HttpUtils/ServerUrlTest.php new file mode 100644 index 00000000..9caf1049 --- /dev/null +++ b/tests/http/HttpUtils/ServerUrlTest.php @@ -0,0 +1,223 @@ +assertEquals( + 'https://host.tld', + server_url( + array( + 'HTTPS' => 'ON', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '443' + ) + ) + ); + + $this->assertEquals( + 'https://host.tld:8080', + server_url( + array( + 'HTTPS' => 'ON', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '8080' + ) + ) + ); + } + + /** + * Detect a Proxy that sets Forwarded-Host + */ + public function testHttpsProxyForwardedHost() + { + $this->assertEquals( + 'https://host.tld:8080', + server_url( + array( + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_PORT' => '8080', + 'HTTP_X_FORWARDED_HOST' => 'host.tld' + ) + ) + ); + + $this->assertEquals( + 'https://host.tld:4974', + server_url( + array( + 'HTTP_X_FORWARDED_PROTO' => 'https, https', + 'HTTP_X_FORWARDED_PORT' => '4974, 80', + 'HTTP_X_FORWARDED_HOST' => 'host.tld, example.com' + ) + ) + ); + } + + /** + * Detect a Proxy with SSL enabled + */ + public function testHttpsProxyForward() + { + $this->assertEquals( + 'https://host.tld:8080', + server_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_PORT' => '8080' + ) + ) + ); + + $this->assertEquals( + 'https://host.tld', + server_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'https' + ) + ) + ); + + $this->assertEquals( + 'https://host.tld', + server_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'https', + 'HTTP_X_FORWARDED_PORT' => '443' + ) + ) + ); + + $this->assertEquals( + 'https://host.tld:4974', + server_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'https, https', + 'HTTP_X_FORWARDED_PORT' => '4974, 80' + ) + ) + ); + } + + /** + * Detect if the server uses a specific port (!= 80) + */ + public function testPort() + { + // HTTP + $this->assertEquals( + 'http://host.tld:8080', + server_url( + array( + 'HTTPS' => 'OFF', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '8080' + ) + ) + ); + + // HTTPS + $this->assertEquals( + 'https://host.tld:8080', + server_url( + array( + 'HTTPS' => 'ON', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '8080' + ) + ) + ); + } + + /** + * HTTP server on port 80 + */ + public function testStandardHttpPort() + { + $this->assertEquals( + 'http://host.tld', + server_url( + array( + 'HTTPS' => 'OFF', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80' + ) + ) + ); + } + + /** + * HTTPS server on port 443 + */ + public function testStandardHttpsPort() + { + $this->assertEquals( + 'https://host.tld', + server_url( + array( + 'HTTPS' => 'ON', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '443' + ) + ) + ); + } + + /** + * Misconfigured server (see #1022): Proxy HTTP but 443 + */ + public function testHttpWithPort433() + { + $this->assertEquals( + 'https://host.tld', + server_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'http', + 'HTTP_X_FORWARDED_PORT' => '443' + ) + ) + ); + + $this->assertEquals( + 'https://host.tld', + server_url( + array( + 'HTTPS' => 'Off', + 'SERVER_NAME' => 'host.tld', + 'SERVER_PORT' => '80', + 'HTTP_X_FORWARDED_PROTO' => 'https, http', + 'HTTP_X_FORWARDED_PORT' => '443, 80' + ) + ) + ); + } +} diff --git a/tests/http/UrlTest.php b/tests/http/UrlTest.php index 011b416d..342b78a4 100644 --- a/tests/http/UrlTest.php +++ b/tests/http/UrlTest.php @@ -1,6 +1,6 @@ assertEquals('', cleanup_url('')); + } + + /** + * Clean an already cleaned URL + */ + public function testCleanupUrlAlreadyClean() + { + $this->assertEquals($this->ref, cleanup_url($this->ref)); + $this->ref2 = $this->ref.'/path/to/dir/'; + $this->assertEquals($this->ref2, cleanup_url($this->ref2)); + } + + /** + * Clean URL fragments + */ + public function testCleanupUrlFragment() + { + $this->assertEquals($this->ref, cleanup_url($this->ref.'#tk.rss_all')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'#xtor=RSS-U3ht0tkc4b')); + } + + /** + * Clean URL query - single annoying parameter + */ + public function testCleanupUrlQuerySingle() + { + $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_object_map=junk')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_ref_map=Cr4p!')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?action_type_map=g4R84g3')); + + $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb_stuff=v41u3')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?fb=71m3w4573')); + + $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_campaign=zomg')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_medium=numnum')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_source=c0d3')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?utm_term=1n4l')); + + $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url')); + + $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_name=junk')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_start=junk')); + $this->assertEquals($this->ref, cleanup_url($this->ref.'?campaign_item_index=junk')); + } + + /** + * Clean URL query - multiple annoying parameters + */ + public function testCleanupUrlQueryMultiple() + { + $this->assertEquals($this->ref, cleanup_url($this->ref.'?xtor=some-url&fb=som3th1ng')); + + $this->assertEquals($this->ref, cleanup_url( + $this->ref.'?fb=stuff&utm_campaign=zomg&utm_medium=numnum&utm_source=c0d3' + )); + + $this->assertEquals($this->ref, cleanup_url( + $this->ref.'?campaign_start=zomg&campaign_name=numnum' + )); + } + + /** + * Clean URL query - multiple annoying parameters and fragment + */ + public function testCleanupUrlQueryFragment() + { + $this->assertEquals($this->ref, cleanup_url( + $this->ref.'?xtor=some-url&fb=som3th1ng#tk.rss_all' + )); + + // ditch annoying query params and fragment, keep useful params + $this->assertEquals( + $this->ref.'?my=stuff&is=kept', + cleanup_url( + $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#tk.rss_all' + ) + ); + + // ditch annoying query params, keep useful params and fragment + $this->assertEquals( + $this->ref.'?my=stuff&is=kept#again', + cleanup_url( + $this->ref.'?fb=zomg&my=stuff&utm_medium=numnum&is=kept#again' + ) + ); + } +} diff --git a/tests/http/UrlUtils/GetUrlSchemeTest.php b/tests/http/UrlUtils/GetUrlSchemeTest.php new file mode 100644 index 00000000..2b97f7be --- /dev/null +++ b/tests/http/UrlUtils/GetUrlSchemeTest.php @@ -0,0 +1,32 @@ +assertEquals('', get_url_scheme('')); + } + + /** + * Get normal scheme of UrlUtils + */ + public function testGetUrlScheme() + { + $this->assertEquals('http', get_url_scheme('http://domain.tld:3000')); + $this->assertEquals('https', get_url_scheme('https://domain.tld:3000')); + $this->assertEquals('http', get_url_scheme('domain.tld')); + $this->assertEquals('ssh', get_url_scheme('ssh://domain.tld')); + $this->assertEquals('ftp', get_url_scheme('ftp://domain.tld')); + $this->assertEquals('git', get_url_scheme('git://domain.tld/push?pull=clone#checkout')); + } +} diff --git a/tests/http/UrlUtils/UnparseUrlTest.php b/tests/http/UrlUtils/UnparseUrlTest.php new file mode 100644 index 00000000..040d8c54 --- /dev/null +++ b/tests/http/UrlUtils/UnparseUrlTest.php @@ -0,0 +1,32 @@ +assertEquals('', unparse_url(array())); + } + + /** + * Rebuild a full-featured URL + */ + public function testUnparseFull() + { + $ref = 'http://username:password@hostname:9090/path' + .'?arg1=value1&arg2=value2#anchor'; + $this->assertEquals($ref, unparse_url(parse_url($ref))); + } +} diff --git a/tests/http/UrlUtils/WhitelistProtocolsTest.php b/tests/http/UrlUtils/WhitelistProtocolsTest.php new file mode 100644 index 00000000..69512dbd --- /dev/null +++ b/tests/http/UrlUtils/WhitelistProtocolsTest.php @@ -0,0 +1,63 @@ +assertEquals($url, whitelist_protocols($url, $whitelist)); + $url = '/path.jpg'; + $this->assertEquals($url, whitelist_protocols($url, $whitelist)); + } + + /** + * Test whitelist_protocols() on a note (relative URL). + */ + public function testWhitelistProtocolMissing() + { + $whitelist = ['ftp', 'magnet']; + $url = 'test.tld/path/?query=value#hash'; + $this->assertEquals('http://'. $url, whitelist_protocols($url, $whitelist)); + } + + /** + * Test whitelist_protocols() with allowed protocols. + */ + public function testWhitelistAllowedProtocol() + { + $whitelist = ['ftp', 'magnet']; + $url = 'http://test.tld/path/?query=value#hash'; + $this->assertEquals($url, whitelist_protocols($url, $whitelist)); + $url = 'https://test.tld/path/?query=value#hash'; + $this->assertEquals($url, whitelist_protocols($url, $whitelist)); + $url = 'ftp://test.tld/path/?query=value#hash'; + $this->assertEquals($url, whitelist_protocols($url, $whitelist)); + $url = 'magnet:test.tld/path/?query=value#hash'; + $this->assertEquals($url, whitelist_protocols($url, $whitelist)); + } + + /** + * Test whitelist_protocols() with allowed protocols. + */ + public function testWhitelistDisallowedProtocol() + { + $whitelist = ['ftp', 'magnet']; + $url = 'javascript:alert("xss");'; + $this->assertEquals('http://alert("xss");', whitelist_protocols($url, $whitelist)); + $url = 'other://test.tld/path/?query=value#hash'; + $this->assertEquals('http://test.tld/path/?query=value#hash', whitelist_protocols($url, $whitelist)); + } +} -- cgit v1.2.3 From 8c0f19c7971e1a4534347ce9d6d82a0a45799711 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 3 Dec 2018 00:46:04 +0100 Subject: namespacing: \Shaarli\Render\{PageBuilder,ThemeUtils} Signed-off-by: VirtualTam --- application/PageBuilder.php | 205 ----------------------------------- application/ThemeUtils.php | 34 ------ application/render/PageBuilder.php | 213 +++++++++++++++++++++++++++++++++++++ application/render/ThemeUtils.php | 34 ++++++ composer.json | 1 + index.php | 4 +- tests/ThemeUtilsTest.php | 55 ---------- tests/render/ThemeUtilsTest.php | 55 ++++++++++ 8 files changed, 305 insertions(+), 296 deletions(-) delete mode 100644 application/PageBuilder.php delete mode 100644 application/ThemeUtils.php create mode 100644 application/render/PageBuilder.php create mode 100644 application/render/ThemeUtils.php delete mode 100644 tests/ThemeUtilsTest.php create mode 100644 tests/render/ThemeUtilsTest.php diff --git a/application/PageBuilder.php b/application/PageBuilder.php deleted file mode 100644 index 2ca95832..00000000 --- a/application/PageBuilder.php +++ /dev/null @@ -1,205 +0,0 @@ -assign('myfield','myvalue'); - * $p->renderPage('mytemplate'); - */ -class PageBuilder -{ - /** - * @var RainTPL RainTPL instance. - */ - private $tpl; - - /** - * @var ConfigManager $conf Configuration Manager instance. - */ - protected $conf; - - /** - * @var array $_SESSION - */ - protected $session; - - /** - * @var LinkDB $linkDB instance. - */ - protected $linkDB; - - /** - * @var null|string XSRF token - */ - protected $token; - - /** @var bool $isLoggedIn Whether the user is logged in **/ - protected $isLoggedIn = false; - - /** - * PageBuilder constructor. - * $tpl is initialized at false for lazy loading. - * - * @param ConfigManager $conf Configuration Manager instance (reference). - * @param array $session $_SESSION array - * @param LinkDB $linkDB instance. - * @param string $token Session token - * @param bool $isLoggedIn - */ - public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) - { - $this->tpl = false; - $this->conf = $conf; - $this->session = $session; - $this->linkDB = $linkDB; - $this->token = $token; - $this->isLoggedIn = $isLoggedIn; - } - - /** - * Initialize all default tpl tags. - */ - private function initialize() - { - $this->tpl = new RainTPL(); - - try { - $version = ApplicationUtils::checkUpdate( - SHAARLI_VERSION, - $this->conf->get('resource.update_check'), - $this->conf->get('updates.check_updates_interval'), - $this->conf->get('updates.check_updates'), - $this->isLoggedIn, - $this->conf->get('updates.check_updates_branch') - ); - $this->tpl->assign('newVersion', escape($version)); - $this->tpl->assign('versionError', ''); - } catch (Exception $exc) { - logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); - $this->tpl->assign('newVersion', ''); - $this->tpl->assign('versionError', escape($exc->getMessage())); - } - - $this->tpl->assign('is_logged_in', $this->isLoggedIn); - $this->tpl->assign('feedurl', escape(index_url($_SERVER))); - $searchcrits = ''; // Search criteria - if (!empty($_GET['searchtags'])) { - $searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']); - } - if (!empty($_GET['searchterm'])) { - $searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']); - } - $this->tpl->assign('searchcrits', $searchcrits); - $this->tpl->assign('source', index_url($_SERVER)); - $this->tpl->assign('version', SHAARLI_VERSION); - $this->tpl->assign( - 'version_hash', - ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt')) - ); - $this->tpl->assign('index_url', index_url($_SERVER)); - $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : ''; - $this->tpl->assign('visibility', $visibility); - $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly'])); - $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli')); - if ($this->conf->exists('general.header_link')) { - $this->tpl->assign('titleLink', $this->conf->get('general.header_link')); - } - $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli')); - $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false)); - $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', true)); - $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss'); - $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false)); - $this->tpl->assign('token', $this->token); - - if ($this->linkDB !== null) { - $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); - } - - $this->tpl->assign( - 'thumbnails_enabled', - $this->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE - ); - $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); - $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); - - if (! empty($_SESSION['warnings'])) { - $this->tpl->assign('global_warnings', $_SESSION['warnings']); - unset($_SESSION['warnings']); - } - - // To be removed with a proper theme configuration. - $this->tpl->assign('conf', $this->conf); - } - - /** - * The following assign() method is basically the same as RainTPL (except lazy loading) - * - * @param string $placeholder Template placeholder. - * @param mixed $value Value to assign. - */ - public function assign($placeholder, $value) - { - if ($this->tpl === false) { - $this->initialize(); - } - $this->tpl->assign($placeholder, $value); - } - - /** - * Assign an array of data to the template builder. - * - * @param array $data Data to assign. - * - * @return false if invalid data. - */ - public function assignAll($data) - { - if ($this->tpl === false) { - $this->initialize(); - } - - if (empty($data) || !is_array($data)) { - return false; - } - - foreach ($data as $key => $value) { - $this->assign($key, $value); - } - return true; - } - - /** - * Render a specific page (using a template file). - * e.g. $pb->renderPage('picwall'); - * - * @param string $page Template filename (without extension). - */ - public function renderPage($page) - { - if ($this->tpl === false) { - $this->initialize(); - } - - $this->tpl->draw($page); - } - - /** - * Render a 404 page (uses the template : tpl/404.tpl) - * usage : $PAGE->render404('The link was deleted') - * - * @param string $message A messate to display what is not found - */ - public function render404($message = '') - { - if (empty($message)) { - $message = t('The page you are trying to reach does not exist or has been deleted.'); - } - header($_SERVER['SERVER_PROTOCOL'] .' '. t('404 Not Found')); - $this->tpl->assign('error_message', $message); - $this->renderPage('404'); - } -} diff --git a/application/ThemeUtils.php b/application/ThemeUtils.php deleted file mode 100644 index 16f2f6a2..00000000 --- a/application/ThemeUtils.php +++ /dev/null @@ -1,34 +0,0 @@ -assign('myfield','myvalue'); + * $p->renderPage('mytemplate'); + */ +class PageBuilder +{ + /** + * @var RainTPL RainTPL instance. + */ + private $tpl; + + /** + * @var ConfigManager $conf Configuration Manager instance. + */ + protected $conf; + + /** + * @var array $_SESSION + */ + protected $session; + + /** + * @var LinkDB $linkDB instance. + */ + protected $linkDB; + + /** + * @var null|string XSRF token + */ + protected $token; + + /** + * @var bool $isLoggedIn Whether the user is logged in + */ + protected $isLoggedIn = false; + + /** + * PageBuilder constructor. + * $tpl is initialized at false for lazy loading. + * + * @param ConfigManager $conf Configuration Manager instance (reference). + * @param array $session $_SESSION array + * @param LinkDB $linkDB instance. + * @param string $token Session token + * @param bool $isLoggedIn + */ + public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) + { + $this->tpl = false; + $this->conf = $conf; + $this->session = $session; + $this->linkDB = $linkDB; + $this->token = $token; + $this->isLoggedIn = $isLoggedIn; + } + + /** + * Initialize all default tpl tags. + */ + private function initialize() + { + $this->tpl = new RainTPL(); + + try { + $version = ApplicationUtils::checkUpdate( + SHAARLI_VERSION, + $this->conf->get('resource.update_check'), + $this->conf->get('updates.check_updates_interval'), + $this->conf->get('updates.check_updates'), + $this->isLoggedIn, + $this->conf->get('updates.check_updates_branch') + ); + $this->tpl->assign('newVersion', escape($version)); + $this->tpl->assign('versionError', ''); + } catch (Exception $exc) { + logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); + $this->tpl->assign('newVersion', ''); + $this->tpl->assign('versionError', escape($exc->getMessage())); + } + + $this->tpl->assign('is_logged_in', $this->isLoggedIn); + $this->tpl->assign('feedurl', escape(index_url($_SERVER))); + $searchcrits = ''; // Search criteria + if (!empty($_GET['searchtags'])) { + $searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']); + } + if (!empty($_GET['searchterm'])) { + $searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']); + } + $this->tpl->assign('searchcrits', $searchcrits); + $this->tpl->assign('source', index_url($_SERVER)); + $this->tpl->assign('version', SHAARLI_VERSION); + $this->tpl->assign( + 'version_hash', + ApplicationUtils::getVersionHash(SHAARLI_VERSION, $this->conf->get('credentials.salt')) + ); + $this->tpl->assign('index_url', index_url($_SERVER)); + $visibility = !empty($_SESSION['visibility']) ? $_SESSION['visibility'] : ''; + $this->tpl->assign('visibility', $visibility); + $this->tpl->assign('untaggedonly', !empty($_SESSION['untaggedonly'])); + $this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli')); + if ($this->conf->exists('general.header_link')) { + $this->tpl->assign('titleLink', $this->conf->get('general.header_link')); + } + $this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli')); + $this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false)); + $this->tpl->assign('showatom', $this->conf->get('feed.show_atom', true)); + $this->tpl->assign('feed_type', $this->conf->get('feed.show_atom', true) !== false ? 'atom' : 'rss'); + $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false)); + $this->tpl->assign('token', $this->token); + + if ($this->linkDB !== null) { + $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); + } + + $this->tpl->assign( + 'thumbnails_enabled', + $this->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + ); + $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width')); + $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height')); + + if (!empty($_SESSION['warnings'])) { + $this->tpl->assign('global_warnings', $_SESSION['warnings']); + unset($_SESSION['warnings']); + } + + // To be removed with a proper theme configuration. + $this->tpl->assign('conf', $this->conf); + } + + /** + * The following assign() method is basically the same as RainTPL (except lazy loading) + * + * @param string $placeholder Template placeholder. + * @param mixed $value Value to assign. + */ + public function assign($placeholder, $value) + { + if ($this->tpl === false) { + $this->initialize(); + } + $this->tpl->assign($placeholder, $value); + } + + /** + * Assign an array of data to the template builder. + * + * @param array $data Data to assign. + * + * @return false if invalid data. + */ + public function assignAll($data) + { + if ($this->tpl === false) { + $this->initialize(); + } + + if (empty($data) || !is_array($data)) { + return false; + } + + foreach ($data as $key => $value) { + $this->assign($key, $value); + } + return true; + } + + /** + * Render a specific page (using a template file). + * e.g. $pb->renderPage('picwall'); + * + * @param string $page Template filename (without extension). + */ + public function renderPage($page) + { + if ($this->tpl === false) { + $this->initialize(); + } + + $this->tpl->draw($page); + } + + /** + * Render a 404 page (uses the template : tpl/404.tpl) + * usage: $PAGE->render404('The link was deleted') + * + * @param string $message A message to display what is not found + */ + public function render404($message = '') + { + if (empty($message)) { + $message = t('The page you are trying to reach does not exist or has been deleted.'); + } + header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found')); + $this->tpl->assign('error_message', $message); + $this->renderPage('404'); + } +} diff --git a/application/render/ThemeUtils.php b/application/render/ThemeUtils.php new file mode 100644 index 00000000..86096c64 --- /dev/null +++ b/application/render/ThemeUtils.php @@ -0,0 +1,34 @@ +assertTrue(in_array($theme, $themes)); - } - $this->assertFalse(in_array('supertheme', $res)); - - foreach ($themes as $theme) { - rmdir('sandbox/tpl/'. $theme); - } - unlink('sandbox/tpl/supertheme'); - rmdir('sandbox/tpl'); - } - - /** - * Test getThemes() without any theme dir. - */ - public function testGetThemesEmpty() - { - mkdir('sandbox/tpl/', 0755, true); - $this->assertEquals([], ThemeUtils::getThemes('sandbox/tpl/')); - rmdir('sandbox/tpl/'); - } - - /** - * Test getThemes() with an invalid path. - */ - public function testGetThemesInvalid() - { - $this->assertEquals([], ThemeUtils::getThemes('nope')); - } -} diff --git a/tests/render/ThemeUtilsTest.php b/tests/render/ThemeUtilsTest.php new file mode 100644 index 00000000..6159a1bd --- /dev/null +++ b/tests/render/ThemeUtilsTest.php @@ -0,0 +1,55 @@ +assertTrue(in_array($theme, $themes)); + } + $this->assertFalse(in_array('supertheme', $res)); + + foreach ($themes as $theme) { + rmdir('sandbox/tpl/'. $theme); + } + unlink('sandbox/tpl/supertheme'); + rmdir('sandbox/tpl'); + } + + /** + * Test getThemes() without any theme dir. + */ + public function testGetThemesEmpty() + { + mkdir('sandbox/tpl/', 0755, true); + $this->assertEquals([], ThemeUtils::getThemes('sandbox/tpl/')); + rmdir('sandbox/tpl/'); + } + + /** + * Test getThemes() with an invalid path. + */ + public function testGetThemesInvalid() + { + $this->assertEquals([], ThemeUtils::getThemes('nope')); + } +} -- cgit v1.2.3 From a0c4dbd91c41b9ecdd5176c1ac33b55e240d2392 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 3 Dec 2018 00:59:21 +0100 Subject: namespacing: \Shaarli\FileUtils Signed-off-by: VirtualTam --- application/FileUtils.php | 8 ++++---- application/History.php | 5 ++--- application/LinkDB.php | 1 + composer.json | 1 + tests/FileUtilsTest.php | 6 +++--- tests/HistoryTest.php | 1 - tests/utils/ReferenceHistory.php | 1 + 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/application/FileUtils.php b/application/FileUtils.php index ba409821..30560bfc 100644 --- a/application/FileUtils.php +++ b/application/FileUtils.php @@ -1,8 +1,8 @@ =5.6", + "ext-zlib": "*", "shaarli/netscape-bookmark-parser": "^2.1", "erusev/parsedown": "^1.6", "slim/slim": "^3.0", diff --git a/tests/FileUtilsTest.php b/tests/FileUtilsTest.php index 9596dba9..57719175 100644 --- a/tests/FileUtilsTest.php +++ b/tests/FileUtilsTest.php @@ -1,15 +1,15 @@ Date: Mon, 3 Dec 2018 01:10:39 +0100 Subject: namespacing: \Shaarli\Bookmark\LinkDB Signed-off-by: VirtualTam --- application/LinkDB.php | 591 ------------------- application/LinkFilter.php | 2 + application/LinkUtils.php | 2 + application/NetscapeBookmarkUtils.php | 1 + application/Updater.php | 2 + application/api/ApiMiddleware.php | 2 +- application/api/controllers/ApiController.php | 2 +- application/bookmark/LinkDB.php | 601 +++++++++++++++++++ application/feed/CachedPage.php | 1 + application/feed/FeedBuilder.php | 15 +- application/render/PageBuilder.php | 2 +- composer.json | 1 + index.php | 3 +- tests/LinkDBTest.php | 647 -------------------- tests/LinkFilterTest.php | 2 + tests/NetscapeBookmarkUtils/BookmarkExportTest.php | 2 + tests/NetscapeBookmarkUtils/BookmarkImportTest.php | 1 + tests/Updater/DummyUpdater.php | 2 + tests/Updater/UpdaterTest.php | 2 + tests/api/controllers/info/InfoTest.php | 2 +- tests/api/controllers/links/DeleteLinkTest.php | 6 +- tests/api/controllers/links/GetLinkIdTest.php | 4 +- tests/api/controllers/links/GetLinksTest.php | 6 +- tests/api/controllers/links/PostLinkTest.php | 6 +- tests/api/controllers/links/PutLinkTest.php | 6 +- tests/api/controllers/tags/DeleteTagTest.php | 8 +- tests/api/controllers/tags/GetTagNameTest.php | 2 +- tests/api/controllers/tags/GetTagsTest.php | 4 +- tests/api/controllers/tags/PutTagTest.php | 4 +- tests/bookmark/LinkDBTest.php | 653 +++++++++++++++++++++ tests/feed/FeedBuilderTest.php | 4 +- tests/http/UrlTest.php | 1 - tests/plugins/PluginIssoTest.php | 2 + tests/utils/ReferenceLinkDB.php | 3 + 34 files changed, 1315 insertions(+), 1277 deletions(-) delete mode 100644 application/LinkDB.php create mode 100644 application/bookmark/LinkDB.php delete mode 100644 tests/LinkDBTest.php create mode 100644 tests/bookmark/LinkDBTest.php diff --git a/application/LinkDB.php b/application/LinkDB.php deleted file mode 100644 index a5b42727..00000000 --- a/application/LinkDB.php +++ /dev/null @@ -1,591 +0,0 @@ -link offset) - private $urls; - - /** - * @var array List of all links IDS mapped with their array offset. - * Map: id->offset. - */ - protected $ids; - - // List of offset keys (for the Iterator interface implementation) - private $keys; - - // Position in the $this->keys array (for the Iterator interface) - private $position; - - // Is the user logged in? (used to filter private links) - private $loggedIn; - - // Hide public links - private $hidePublicLinks; - - // link redirector set in user settings. - private $redirector; - - /** - * Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched. - * - * Example: - * anonym.to needs clean URL while dereferer.org needs urlencoded URL. - * - * @var boolean $redirectorEncode parameter: true or false - */ - private $redirectorEncode; - - /** - * Creates a new LinkDB - * - * Checks if the datastore exists; else, attempts to create a dummy one. - * - * @param string $datastore datastore file path. - * @param boolean $isLoggedIn is the user logged in? - * @param boolean $hidePublicLinks if true all links are private. - * @param string $redirector link redirector set in user settings. - * @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true). - */ - public function __construct( - $datastore, - $isLoggedIn, - $hidePublicLinks, - $redirector = '', - $redirectorEncode = true - ) { - $this->datastore = $datastore; - $this->loggedIn = $isLoggedIn; - $this->hidePublicLinks = $hidePublicLinks; - $this->redirector = $redirector; - $this->redirectorEncode = $redirectorEncode === true; - $this->check(); - $this->read(); - } - - /** - * Countable - Counts elements of an object - */ - public function count() - { - return count($this->links); - } - - /** - * ArrayAccess - Assigns a value to the specified offset - */ - public function offsetSet($offset, $value) - { - // TODO: use exceptions instead of "die" - if (!$this->loggedIn) { - die(t('You are not authorized to add a link.')); - } - if (!isset($value['id']) || empty($value['url'])) { - die(t('Internal Error: A link should always have an id and URL.')); - } - if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) { - die(t('You must specify an integer as a key.')); - } - if ($offset !== null && $offset !== $value['id']) { - die(t('Array offset and link ID must be equal.')); - } - - // If the link exists, we reuse the real offset, otherwise new entry - $existing = $this->getLinkOffset($offset); - if ($existing !== null) { - $offset = $existing; - } else { - $offset = count($this->links); - } - $this->links[$offset] = $value; - $this->urls[$value['url']] = $offset; - $this->ids[$value['id']] = $offset; - } - - /** - * ArrayAccess - Whether or not an offset exists - */ - public function offsetExists($offset) - { - return array_key_exists($this->getLinkOffset($offset), $this->links); - } - - /** - * ArrayAccess - Unsets an offset - */ - public function offsetUnset($offset) - { - if (!$this->loggedIn) { - // TODO: raise an exception - die('You are not authorized to delete a link.'); - } - $realOffset = $this->getLinkOffset($offset); - $url = $this->links[$realOffset]['url']; - unset($this->urls[$url]); - unset($this->ids[$realOffset]); - unset($this->links[$realOffset]); - } - - /** - * ArrayAccess - Returns the value at specified offset - */ - public function offsetGet($offset) - { - $realOffset = $this->getLinkOffset($offset); - return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null; - } - - /** - * Iterator - Returns the current element - */ - public function current() - { - return $this[$this->keys[$this->position]]; - } - - /** - * Iterator - Returns the key of the current element - */ - public function key() - { - return $this->keys[$this->position]; - } - - /** - * Iterator - Moves forward to next element - */ - public function next() - { - ++$this->position; - } - - /** - * Iterator - Rewinds the Iterator to the first element - * - * Entries are sorted by date (latest first) - */ - public function rewind() - { - $this->keys = array_keys($this->ids); - $this->position = 0; - } - - /** - * Iterator - Checks if current position is valid - */ - public function valid() - { - return isset($this->keys[$this->position]); - } - - /** - * Checks if the DB directory and file exist - * - * If no DB file is found, creates a dummy DB. - */ - private function check() - { - if (file_exists($this->datastore)) { - return; - } - - // Create a dummy database for example - $this->links = array(); - $link = array( - 'id' => 1, - 'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'), - 'url'=>'https://shaarli.readthedocs.io', - 'description'=>t( - 'Welcome to Shaarli! This is your first public bookmark. ' - .'To edit or delete me, you must first login. - -To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. - -You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' - ), - 'private'=>0, - 'created'=> new DateTime(), - 'tags'=>'opensource software' - ); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - $this->links[1] = $link; - - $link = array( - 'id' => 0, - 'title'=> t('My secret stuff... - Pastebin.com'), - 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', - 'description'=> t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), - 'private'=>1, - 'created'=> new DateTime('1 minute ago'), - 'tags'=>'secretstuff', - ); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - $this->links[0] = $link; - - // Write database to disk - $this->write(); - } - - /** - * Reads database from disk to memory - */ - private function read() - { - // Public links are hidden and user not logged in => nothing to show - if ($this->hidePublicLinks && !$this->loggedIn) { - $this->links = array(); - return; - } - - $this->urls = []; - $this->ids = []; - $this->links = FileUtils::readFlatDB($this->datastore, []); - - $toremove = array(); - foreach ($this->links as $key => &$link) { - if (! $this->loggedIn && $link['private'] != 0) { - // Transition for not upgraded databases. - unset($this->links[$key]); - continue; - } - - // Sanitize data fields. - sanitizeLink($link); - - // Remove private tags if the user is not logged in. - if (! $this->loggedIn) { - $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']); - } - - // Do not use the redirector for internal links (Shaarli note URL starting with a '?'). - if (!empty($this->redirector) && !startsWith($link['url'], '?')) { - $link['real_url'] = $this->redirector; - if ($this->redirectorEncode) { - $link['real_url'] .= urlencode(unescape($link['url'])); - } else { - $link['real_url'] .= $link['url']; - } - } else { - $link['real_url'] = $link['url']; - } - - // To be able to load links before running the update, and prepare the update - if (! isset($link['created'])) { - $link['id'] = $link['linkdate']; - $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); - if (! empty($link['updated'])) { - $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']); - } - $link['shorturl'] = smallHash($link['linkdate']); - } - - $this->urls[$link['url']] = $key; - $this->ids[$link['id']] = $key; - } - } - - /** - * Saves the database from memory to disk - * - * @throws IOException the datastore is not writable - */ - private function write() - { - $this->reorder(); - FileUtils::writeFlatDB($this->datastore, $this->links); - } - - /** - * Saves the database from memory to disk - * - * @param string $pageCacheDir page cache directory - */ - public function save($pageCacheDir) - { - if (!$this->loggedIn) { - // TODO: raise an Exception instead - die('You are not authorized to change the database.'); - } - - $this->write(); - - invalidateCaches($pageCacheDir); - } - - /** - * Returns the link for a given URL, or False if it does not exist. - * - * @param string $url URL to search for - * - * @return mixed the existing link if it exists, else 'false' - */ - public function getLinkFromUrl($url) - { - if (isset($this->urls[$url])) { - return $this->links[$this->urls[$url]]; - } - return false; - } - - /** - * Returns the shaare corresponding to a smallHash. - * - * @param string $request QUERY_STRING server parameter. - * - * @return array $filtered array containing permalink data. - * - * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. - */ - public function filterHash($request) - { - $request = substr($request, 0, 6); - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); - } - - /** - * Returns the list of articles for a given day. - * - * @param string $request day to filter. Format: YYYYMMDD. - * - * @return array list of shaare found. - */ - public function filterDay($request) - { - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); - } - - /** - * Filter links according to search parameters. - * - * @param array $filterRequest Search request content. Supported keys: - * - searchtags: list of tags - * - searchterm: term search - * @param bool $casesensitive Optional: Perform case sensitive filter - * @param string $visibility return only all/private/public links - * @param string $untaggedonly return only untagged links - * - * @return array filtered links, all links if no suitable filter was provided. - */ - public function filterSearch( - $filterRequest = array(), - $casesensitive = false, - $visibility = 'all', - $untaggedonly = false - ) { - // Filter link database according to parameters. - $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; - $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; - - // Search tags + fullsearch - blank string parameter will return all links. - $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" - $request = [$searchtags, $searchterm]; - - $linkFilter = new LinkFilter($this); - return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); - } - - /** - * Returns the list tags appearing in the links with the given tags - * - * @param array $filteringTags tags selecting the links to consider - * @param string $visibility process only all/private/public links - * - * @return array tag => linksCount - */ - public function linksCountPerTag($filteringTags = [], $visibility = 'all') - { - $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); - $tags = []; - $caseMapping = []; - foreach ($links as $link) { - foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { - if (empty($tag)) { - continue; - } - // The first case found will be displayed. - if (!isset($caseMapping[strtolower($tag)])) { - $caseMapping[strtolower($tag)] = $tag; - $tags[$caseMapping[strtolower($tag)]] = 0; - } - $tags[$caseMapping[strtolower($tag)]]++; - } - } - - /* - * Formerly used arsort(), which doesn't define the sort behaviour for equal values. - * Also, this function doesn't produce the same result between PHP 5.6 and 7. - * - * So we now use array_multisort() to sort tags by DESC occurrences, - * then ASC alphabetically for equal values. - * - * @see https://github.com/shaarli/Shaarli/issues/1142 - */ - $keys = array_keys($tags); - $tmpTags = array_combine($keys, $keys); - array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); - return $tags; - } - - /** - * Rename or delete a tag across all links. - * - * @param string $from Tag to rename - * @param string $to New tag. If none is provided, the from tag will be deleted - * - * @return array|bool List of altered links or false on error - */ - public function renameTag($from, $to) - { - if (empty($from)) { - return false; - } - $delete = empty($to); - // True for case-sensitive tag search. - $linksToAlter = $this->filterSearch(['searchtags' => $from], true); - foreach ($linksToAlter as $key => &$value) { - $tags = preg_split('/\s+/', trim($value['tags'])); - if (($pos = array_search($from, $tags)) !== false) { - if ($delete) { - unset($tags[$pos]); // Remove tag. - } else { - $tags[$pos] = trim($to); - } - $value['tags'] = trim(implode(' ', array_unique($tags))); - $this[$value['id']] = $value; - } - } - - return $linksToAlter; - } - - /** - * Returns the list of days containing articles (oldest first) - * Output: An array containing days (in format YYYYMMDD). - */ - public function days() - { - $linkDays = array(); - foreach ($this->links as $link) { - $linkDays[$link['created']->format('Ymd')] = 0; - } - $linkDays = array_keys($linkDays); - sort($linkDays); - - return $linkDays; - } - - /** - * Reorder links by creation date (newest first). - * - * Also update the urls and ids mapping arrays. - * - * @param string $order ASC|DESC - */ - public function reorder($order = 'DESC') - { - $order = $order === 'ASC' ? -1 : 1; - // Reorder array by dates. - usort($this->links, function ($a, $b) use ($order) { - if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { - return $a['sticky'] ? -1 : 1; - } - return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; - }); - - $this->urls = []; - $this->ids = []; - foreach ($this->links as $key => $link) { - $this->urls[$link['url']] = $key; - $this->ids[$link['id']] = $key; - } - } - - /** - * Return the next key for link creation. - * E.g. If the last ID is 597, the next will be 598. - * - * @return int next ID. - */ - public function getNextId() - { - if (!empty($this->ids)) { - return max(array_keys($this->ids)) + 1; - } - return 0; - } - - /** - * Returns a link offset in links array from its unique ID. - * - * @param int $id Persistent ID of a link. - * - * @return int Real offset in local array, or null if doesn't exist. - */ - protected function getLinkOffset($id) - { - if (isset($this->ids[$id])) { - return $this->ids[$id]; - } - return null; - } -} diff --git a/application/LinkFilter.php b/application/LinkFilter.php index 8f147974..91c79905 100644 --- a/application/LinkFilter.php +++ b/application/LinkFilter.php @@ -1,5 +1,7 @@ get('resource.datastore'), true, $conf->get('privacy.hide_public_links'), diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index 47e0e178..cab97dc4 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php @@ -25,7 +25,7 @@ abstract class ApiController protected $conf; /** - * @var \LinkDB + * @var \Shaarli\Bookmark\LinkDB */ protected $linkDb; diff --git a/application/bookmark/LinkDB.php b/application/bookmark/LinkDB.php new file mode 100644 index 00000000..3b77422a --- /dev/null +++ b/application/bookmark/LinkDB.php @@ -0,0 +1,601 @@ +link offset) + private $urls; + + /** + * @var array List of all links IDS mapped with their array offset. + * Map: id->offset. + */ + protected $ids; + + // List of offset keys (for the Iterator interface implementation) + private $keys; + + // Position in the $this->keys array (for the Iterator interface) + private $position; + + // Is the user logged in? (used to filter private links) + private $loggedIn; + + // Hide public links + private $hidePublicLinks; + + // link redirector set in user settings. + private $redirector; + + /** + * Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched. + * + * Example: + * anonym.to needs clean URL while dereferer.org needs urlencoded URL. + * + * @var boolean $redirectorEncode parameter: true or false + */ + private $redirectorEncode; + + /** + * Creates a new LinkDB + * + * Checks if the datastore exists; else, attempts to create a dummy one. + * + * @param string $datastore datastore file path. + * @param boolean $isLoggedIn is the user logged in? + * @param boolean $hidePublicLinks if true all links are private. + * @param string $redirector link redirector set in user settings. + * @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true). + */ + public function __construct( + $datastore, + $isLoggedIn, + $hidePublicLinks, + $redirector = '', + $redirectorEncode = true + ) { + + $this->datastore = $datastore; + $this->loggedIn = $isLoggedIn; + $this->hidePublicLinks = $hidePublicLinks; + $this->redirector = $redirector; + $this->redirectorEncode = $redirectorEncode === true; + $this->check(); + $this->read(); + } + + /** + * Countable - Counts elements of an object + */ + public function count() + { + return count($this->links); + } + + /** + * ArrayAccess - Assigns a value to the specified offset + */ + public function offsetSet($offset, $value) + { + // TODO: use exceptions instead of "die" + if (!$this->loggedIn) { + die(t('You are not authorized to add a link.')); + } + if (!isset($value['id']) || empty($value['url'])) { + die(t('Internal Error: A link should always have an id and URL.')); + } + if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) { + die(t('You must specify an integer as a key.')); + } + if ($offset !== null && $offset !== $value['id']) { + die(t('Array offset and link ID must be equal.')); + } + + // If the link exists, we reuse the real offset, otherwise new entry + $existing = $this->getLinkOffset($offset); + if ($existing !== null) { + $offset = $existing; + } else { + $offset = count($this->links); + } + $this->links[$offset] = $value; + $this->urls[$value['url']] = $offset; + $this->ids[$value['id']] = $offset; + } + + /** + * ArrayAccess - Whether or not an offset exists + */ + public function offsetExists($offset) + { + return array_key_exists($this->getLinkOffset($offset), $this->links); + } + + /** + * ArrayAccess - Unsets an offset + */ + public function offsetUnset($offset) + { + if (!$this->loggedIn) { + // TODO: raise an exception + die('You are not authorized to delete a link.'); + } + $realOffset = $this->getLinkOffset($offset); + $url = $this->links[$realOffset]['url']; + unset($this->urls[$url]); + unset($this->ids[$realOffset]); + unset($this->links[$realOffset]); + } + + /** + * ArrayAccess - Returns the value at specified offset + */ + public function offsetGet($offset) + { + $realOffset = $this->getLinkOffset($offset); + return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null; + } + + /** + * Iterator - Returns the current element + */ + public function current() + { + return $this[$this->keys[$this->position]]; + } + + /** + * Iterator - Returns the key of the current element + */ + public function key() + { + return $this->keys[$this->position]; + } + + /** + * Iterator - Moves forward to next element + */ + public function next() + { + ++$this->position; + } + + /** + * Iterator - Rewinds the Iterator to the first element + * + * Entries are sorted by date (latest first) + */ + public function rewind() + { + $this->keys = array_keys($this->ids); + $this->position = 0; + } + + /** + * Iterator - Checks if current position is valid + */ + public function valid() + { + return isset($this->keys[$this->position]); + } + + /** + * Checks if the DB directory and file exist + * + * If no DB file is found, creates a dummy DB. + */ + private function check() + { + if (file_exists($this->datastore)) { + return; + } + + // Create a dummy database for example + $this->links = array(); + $link = array( + 'id' => 1, + 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), + 'url' => 'https://shaarli.readthedocs.io', + 'description' => t( + 'Welcome to Shaarli! This is your first public bookmark. ' + . 'To edit or delete me, you must first login. + +To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. + +You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' + ), + 'private' => 0, + 'created' => new DateTime(), + 'tags' => 'opensource software' + ); + $link['shorturl'] = link_small_hash($link['created'], $link['id']); + $this->links[1] = $link; + + $link = array( + 'id' => 0, + 'title' => t('My secret stuff... - Pastebin.com'), + 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', + 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), + 'private' => 1, + 'created' => new DateTime('1 minute ago'), + 'tags' => 'secretstuff', + ); + $link['shorturl'] = link_small_hash($link['created'], $link['id']); + $this->links[0] = $link; + + // Write database to disk + $this->write(); + } + + /** + * Reads database from disk to memory + */ + private function read() + { + // Public links are hidden and user not logged in => nothing to show + if ($this->hidePublicLinks && !$this->loggedIn) { + $this->links = array(); + return; + } + + $this->urls = []; + $this->ids = []; + $this->links = FileUtils::readFlatDB($this->datastore, []); + + $toremove = array(); + foreach ($this->links as $key => &$link) { + if (!$this->loggedIn && $link['private'] != 0) { + // Transition for not upgraded databases. + unset($this->links[$key]); + continue; + } + + // Sanitize data fields. + sanitizeLink($link); + + // Remove private tags if the user is not logged in. + if (!$this->loggedIn) { + $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']); + } + + // Do not use the redirector for internal links (Shaarli note URL starting with a '?'). + if (!empty($this->redirector) && !startsWith($link['url'], '?')) { + $link['real_url'] = $this->redirector; + if ($this->redirectorEncode) { + $link['real_url'] .= urlencode(unescape($link['url'])); + } else { + $link['real_url'] .= $link['url']; + } + } else { + $link['real_url'] = $link['url']; + } + + // To be able to load links before running the update, and prepare the update + if (!isset($link['created'])) { + $link['id'] = $link['linkdate']; + $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); + if (!empty($link['updated'])) { + $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']); + } + $link['shorturl'] = smallHash($link['linkdate']); + } + + $this->urls[$link['url']] = $key; + $this->ids[$link['id']] = $key; + } + } + + /** + * Saves the database from memory to disk + * + * @throws IOException the datastore is not writable + */ + private function write() + { + $this->reorder(); + FileUtils::writeFlatDB($this->datastore, $this->links); + } + + /** + * Saves the database from memory to disk + * + * @param string $pageCacheDir page cache directory + */ + public function save($pageCacheDir) + { + if (!$this->loggedIn) { + // TODO: raise an Exception instead + die('You are not authorized to change the database.'); + } + + $this->write(); + + invalidateCaches($pageCacheDir); + } + + /** + * Returns the link for a given URL, or False if it does not exist. + * + * @param string $url URL to search for + * + * @return mixed the existing link if it exists, else 'false' + */ + public function getLinkFromUrl($url) + { + if (isset($this->urls[$url])) { + return $this->links[$this->urls[$url]]; + } + return false; + } + + /** + * Returns the shaare corresponding to a smallHash. + * + * @param string $request QUERY_STRING server parameter. + * + * @return array $filtered array containing permalink data. + * + * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. + */ + public function filterHash($request) + { + $request = substr($request, 0, 6); + $linkFilter = new LinkFilter($this->links); + return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); + } + + /** + * Returns the list of articles for a given day. + * + * @param string $request day to filter. Format: YYYYMMDD. + * + * @return array list of shaare found. + */ + public function filterDay($request) + { + $linkFilter = new LinkFilter($this->links); + return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); + } + + /** + * Filter links according to search parameters. + * + * @param array $filterRequest Search request content. Supported keys: + * - searchtags: list of tags + * - searchterm: term search + * @param bool $casesensitive Optional: Perform case sensitive filter + * @param string $visibility return only all/private/public links + * @param string $untaggedonly return only untagged links + * + * @return array filtered links, all links if no suitable filter was provided. + */ + public function filterSearch( + $filterRequest = array(), + $casesensitive = false, + $visibility = 'all', + $untaggedonly = false + ) { + + // Filter link database according to parameters. + $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; + $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; + + // Search tags + fullsearch - blank string parameter will return all links. + $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" + $request = [$searchtags, $searchterm]; + + $linkFilter = new LinkFilter($this); + return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); + } + + /** + * Returns the list tags appearing in the links with the given tags + * + * @param array $filteringTags tags selecting the links to consider + * @param string $visibility process only all/private/public links + * + * @return array tag => linksCount + */ + public function linksCountPerTag($filteringTags = [], $visibility = 'all') + { + $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); + $tags = []; + $caseMapping = []; + foreach ($links as $link) { + foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { + if (empty($tag)) { + continue; + } + // The first case found will be displayed. + if (!isset($caseMapping[strtolower($tag)])) { + $caseMapping[strtolower($tag)] = $tag; + $tags[$caseMapping[strtolower($tag)]] = 0; + } + $tags[$caseMapping[strtolower($tag)]]++; + } + } + + /* + * Formerly used arsort(), which doesn't define the sort behaviour for equal values. + * Also, this function doesn't produce the same result between PHP 5.6 and 7. + * + * So we now use array_multisort() to sort tags by DESC occurrences, + * then ASC alphabetically for equal values. + * + * @see https://github.com/shaarli/Shaarli/issues/1142 + */ + $keys = array_keys($tags); + $tmpTags = array_combine($keys, $keys); + array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); + return $tags; + } + + /** + * Rename or delete a tag across all links. + * + * @param string $from Tag to rename + * @param string $to New tag. If none is provided, the from tag will be deleted + * + * @return array|bool List of altered links or false on error + */ + public function renameTag($from, $to) + { + if (empty($from)) { + return false; + } + $delete = empty($to); + // True for case-sensitive tag search. + $linksToAlter = $this->filterSearch(['searchtags' => $from], true); + foreach ($linksToAlter as $key => &$value) { + $tags = preg_split('/\s+/', trim($value['tags'])); + if (($pos = array_search($from, $tags)) !== false) { + if ($delete) { + unset($tags[$pos]); // Remove tag. + } else { + $tags[$pos] = trim($to); + } + $value['tags'] = trim(implode(' ', array_unique($tags))); + $this[$value['id']] = $value; + } + } + + return $linksToAlter; + } + + /** + * Returns the list of days containing articles (oldest first) + * Output: An array containing days (in format YYYYMMDD). + */ + public function days() + { + $linkDays = array(); + foreach ($this->links as $link) { + $linkDays[$link['created']->format('Ymd')] = 0; + } + $linkDays = array_keys($linkDays); + sort($linkDays); + + return $linkDays; + } + + /** + * Reorder links by creation date (newest first). + * + * Also update the urls and ids mapping arrays. + * + * @param string $order ASC|DESC + */ + public function reorder($order = 'DESC') + { + $order = $order === 'ASC' ? -1 : 1; + // Reorder array by dates. + usort($this->links, function ($a, $b) use ($order) { + if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { + return $a['sticky'] ? -1 : 1; + } + return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; + }); + + $this->urls = []; + $this->ids = []; + foreach ($this->links as $key => $link) { + $this->urls[$link['url']] = $key; + $this->ids[$link['id']] = $key; + } + } + + /** + * Return the next key for link creation. + * E.g. If the last ID is 597, the next will be 598. + * + * @return int next ID. + */ + public function getNextId() + { + if (!empty($this->ids)) { + return max(array_keys($this->ids)) + 1; + } + return 0; + } + + /** + * Returns a link offset in links array from its unique ID. + * + * @param int $id Persistent ID of a link. + * + * @return int Real offset in local array, or null if doesn't exist. + */ + protected function getLinkOffset($id) + { + if (isset($this->ids[$id])) { + return $this->ids[$id]; + } + return null; + } +} diff --git a/application/feed/CachedPage.php b/application/feed/CachedPage.php index 1c51ac73..d809bdd9 100644 --- a/application/feed/CachedPage.php +++ b/application/feed/CachedPage.php @@ -1,6 +1,7 @@ write(self::$testDatastore); - - self::$publicLinkDB = new LinkDB(self::$testDatastore, false, false); - self::$privateLinkDB = new LinkDB(self::$testDatastore, true, false); - } - - /** - * Resets test data for each test - */ - protected function setUp() - { - if (file_exists(self::$testDatastore)) { - unlink(self::$testDatastore); - } - } - - /** - * Allows to test LinkDB's private methods - * - * @see - * https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html - * http://stackoverflow.com/a/2798203 - */ - protected static function getMethod($name) - { - $class = new ReflectionClass('LinkDB'); - $method = $class->getMethod($name); - $method->setAccessible(true); - return $method; - } - - /** - * Instantiate LinkDB objects - logged in user - */ - public function testConstructLoggedIn() - { - new LinkDB(self::$testDatastore, true, false); - $this->assertFileExists(self::$testDatastore); - } - - /** - * Instantiate LinkDB objects - logged out or public instance - */ - public function testConstructLoggedOut() - { - new LinkDB(self::$testDatastore, false, false); - $this->assertFileExists(self::$testDatastore); - } - - /** - * Attempt to instantiate a LinkDB whereas the datastore is not writable - * - * @expectedException Shaarli\Exceptions\IOException - * @expectedExceptionMessageRegExp /Error accessing "null"/ - */ - public function testConstructDatastoreNotWriteable() - { - new LinkDB('null/store.db', false, false); - } - - /** - * The DB doesn't exist, ensure it is created with dummy content - */ - public function testCheckDBNew() - { - $linkDB = new LinkDB(self::$testDatastore, false, false); - unlink(self::$testDatastore); - $this->assertFileNotExists(self::$testDatastore); - - $checkDB = self::getMethod('check'); - $checkDB->invokeArgs($linkDB, array()); - $this->assertFileExists(self::$testDatastore); - - // ensure the correct data has been written - $this->assertGreaterThan(0, filesize(self::$testDatastore)); - } - - /** - * The DB exists, don't do anything - */ - public function testCheckDBLoad() - { - $linkDB = new LinkDB(self::$testDatastore, false, false); - $datastoreSize = filesize(self::$testDatastore); - $this->assertGreaterThan(0, $datastoreSize); - - $checkDB = self::getMethod('check'); - $checkDB->invokeArgs($linkDB, array()); - - // ensure the datastore is left unmodified - $this->assertEquals( - $datastoreSize, - filesize(self::$testDatastore) - ); - } - - /** - * Load an empty DB - */ - public function testReadEmptyDB() - { - file_put_contents(self::$testDatastore, ''); - $emptyDB = new LinkDB(self::$testDatastore, false, false); - $this->assertEquals(0, sizeof($emptyDB)); - $this->assertEquals(0, count($emptyDB)); - } - - /** - * Load public links from the DB - */ - public function testReadPublicDB() - { - $this->assertEquals( - self::$refDB->countPublicLinks(), - sizeof(self::$publicLinkDB) - ); - } - - /** - * Load public and private links from the DB - */ - public function testReadPrivateDB() - { - $this->assertEquals( - self::$refDB->countLinks(), - sizeof(self::$privateLinkDB) - ); - } - - /** - * Save the links to the DB - */ - public function testSave() - { - $testDB = new LinkDB(self::$testDatastore, true, false); - $dbSize = sizeof($testDB); - - $link = array( - 'id' => 42, - 'title'=>'an additional link', - 'url'=>'http://dum.my', - 'description'=>'One more', - 'private'=>0, - 'created'=> DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150518_190000'), - 'tags'=>'unit test' - ); - $testDB[$link['id']] = $link; - $testDB->save('tests'); - - $testDB = new LinkDB(self::$testDatastore, true, false); - $this->assertEquals($dbSize + 1, sizeof($testDB)); - } - - /** - * Count existing links - */ - public function testCount() - { - $this->assertEquals( - self::$refDB->countPublicLinks(), - self::$publicLinkDB->count() - ); - $this->assertEquals( - self::$refDB->countLinks(), - self::$privateLinkDB->count() - ); - } - - /** - * Count existing links - public links hidden - */ - public function testCountHiddenPublic() - { - $linkDB = new LinkDB(self::$testDatastore, false, true); - - $this->assertEquals( - 0, - $linkDB->count() - ); - $this->assertEquals( - 0, - $linkDB->count() - ); - } - - /** - * List the days for which links have been posted - */ - public function testDays() - { - $this->assertEquals( - array('20100309', '20100310', '20121206', '20121207', '20130614', '20150310'), - self::$publicLinkDB->days() - ); - - $this->assertEquals( - array('20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'), - self::$privateLinkDB->days() - ); - } - - /** - * The URL corresponds to an existing entry in the DB - */ - public function testGetKnownLinkFromURL() - { - $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/'); - - $this->assertNotEquals(false, $link); - $this->assertContains( - 'A free software media publishing platform', - $link['description'] - ); - } - - /** - * The URL is not in the DB - */ - public function testGetUnknownLinkFromURL() - { - $this->assertEquals( - false, - self::$publicLinkDB->getLinkFromUrl('http://dev.null') - ); - } - - /** - * Lists all tags - */ - public function testAllTags() - { - $this->assertEquals( - array( - 'web' => 3, - 'cartoon' => 2, - 'gnu' => 2, - 'dev' => 1, - 'samba' => 1, - 'media' => 1, - 'software' => 1, - 'stallman' => 1, - 'free' => 1, - '-exclude' => 1, - 'hashtag' => 2, - // The DB contains a link with `sTuff` and another one with `stuff` tag. - // They need to be grouped with the first case found - order by date DESC: `sTuff`. - 'sTuff' => 2, - 'ut' => 1, - ), - self::$publicLinkDB->linksCountPerTag() - ); - - $this->assertEquals( - array( - 'web' => 4, - 'cartoon' => 3, - 'gnu' => 2, - 'dev' => 2, - 'samba' => 1, - 'media' => 1, - 'software' => 1, - 'stallman' => 1, - 'free' => 1, - 'html' => 1, - 'w3c' => 1, - 'css' => 1, - 'Mercurial' => 1, - 'sTuff' => 2, - '-exclude' => 1, - '.hidden' => 1, - 'hashtag' => 2, - 'tag1' => 1, - 'tag2' => 1, - 'tag3' => 1, - 'tag4' => 1, - 'ut' => 1, - ), - self::$privateLinkDB->linksCountPerTag() - ); - $this->assertEquals( - array( - 'web' => 4, - 'cartoon' => 2, - 'gnu' => 1, - 'dev' => 1, - 'samba' => 1, - 'media' => 1, - 'html' => 1, - 'w3c' => 1, - 'css' => 1, - 'Mercurial' => 1, - '.hidden' => 1, - 'hashtag' => 1, - ), - self::$privateLinkDB->linksCountPerTag(['web']) - ); - $this->assertEquals( - array( - 'web' => 1, - 'html' => 1, - 'w3c' => 1, - 'css' => 1, - 'Mercurial' => 1, - ), - self::$privateLinkDB->linksCountPerTag(['web'], 'private') - ); - } - - /** - * Test real_url without redirector. - */ - public function testLinkRealUrlWithoutRedirector() - { - $db = new LinkDB(self::$testDatastore, false, false); - foreach ($db as $link) { - $this->assertEquals($link['url'], $link['real_url']); - } - } - - /** - * Test real_url with redirector. - */ - public function testLinkRealUrlWithRedirector() - { - $redirector = 'http://redirector.to?'; - $db = new LinkDB(self::$testDatastore, false, false, $redirector); - foreach ($db as $link) { - $this->assertStringStartsWith($redirector, $link['real_url']); - $this->assertNotFalse(strpos($link['real_url'], urlencode('://'))); - } - - $db = new LinkDB(self::$testDatastore, false, false, $redirector, false); - foreach ($db as $link) { - $this->assertStringStartsWith($redirector, $link['real_url']); - $this->assertFalse(strpos($link['real_url'], urlencode('://'))); - } - } - - /** - * Test filter with string. - */ - public function testFilterString() - { - $tags = 'dev cartoon'; - $request = array('searchtags' => $tags); - $this->assertEquals( - 2, - count(self::$privateLinkDB->filterSearch($request, true, false)) - ); - } - - /** - * Test filter with string. - */ - public function testFilterArray() - { - $tags = array('dev', 'cartoon'); - $request = array('searchtags' => $tags); - $this->assertEquals( - 2, - count(self::$privateLinkDB->filterSearch($request, true, false)) - ); - } - - /** - * Test hidden tags feature: - * tags starting with a dot '.' are only visible when logged in. - */ - public function testHiddenTags() - { - $tags = '.hidden'; - $request = array('searchtags' => $tags); - $this->assertEquals( - 1, - count(self::$privateLinkDB->filterSearch($request, true, false)) - ); - - $this->assertEquals( - 0, - count(self::$publicLinkDB->filterSearch($request, true, false)) - ); - } - - /** - * Test filterHash() with a valid smallhash. - */ - public function testFilterHashValid() - { - $request = smallHash('20150310_114651'); - $this->assertEquals( - 1, - count(self::$publicLinkDB->filterHash($request)) - ); - $request = smallHash('20150310_114633' . 8); - $this->assertEquals( - 1, - count(self::$publicLinkDB->filterHash($request)) - ); - } - - /** - * Test filterHash() with an invalid smallhash. - * - * @expectedException LinkNotFoundException - */ - public function testFilterHashInValid1() - { - $request = 'blabla'; - self::$publicLinkDB->filterHash($request); - } - - /** - * Test filterHash() with an empty smallhash. - * - * @expectedException LinkNotFoundException - */ - public function testFilterHashInValid() - { - self::$publicLinkDB->filterHash(''); - } - - /** - * Test reorder with asc/desc parameter. - */ - public function testReorderLinksDesc() - { - self::$privateLinkDB->reorder('ASC'); - $stickyIds = [11, 10]; - $standardIds = [42, 4, 9, 1, 0, 7, 6, 8, 41]; - $linkIds = array_merge($stickyIds, $standardIds); - $cpt = 0; - foreach (self::$privateLinkDB as $key => $value) { - $this->assertEquals($linkIds[$cpt++], $key); - } - self::$privateLinkDB->reorder('DESC'); - $linkIds = array_merge(array_reverse($stickyIds), array_reverse($standardIds)); - $cpt = 0; - foreach (self::$privateLinkDB as $key => $value) { - $this->assertEquals($linkIds[$cpt++], $key); - } - } - - /** - * Test rename tag with a valid value present in multiple links - */ - public function testRenameTagMultiple() - { - self::$refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $res = $linkDB->renameTag('cartoon', 'Taz'); - $this->assertEquals(3, count($res)); - $this->assertContains(' Taz ', $linkDB[4]['tags']); - $this->assertContains(' Taz ', $linkDB[1]['tags']); - $this->assertContains(' Taz ', $linkDB[0]['tags']); - } - - /** - * Test rename tag with a valid value - */ - public function testRenameTagCaseSensitive() - { - self::$refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false, ''); - - $res = $linkDB->renameTag('sTuff', 'Taz'); - $this->assertEquals(1, count($res)); - $this->assertEquals('Taz', $linkDB[41]['tags']); - } - - /** - * Test rename tag with invalid values - */ - public function testRenameTagInvalid() - { - $linkDB = new LinkDB(self::$testDatastore, false, false); - - $this->assertFalse($linkDB->renameTag('', 'test')); - $this->assertFalse($linkDB->renameTag('', '')); - // tag non existent - $this->assertEquals([], $linkDB->renameTag('test', '')); - $this->assertEquals([], $linkDB->renameTag('test', 'retest')); - } - - /** - * Test delete tag with a valid value - */ - public function testDeleteTag() - { - self::$refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $res = $linkDB->renameTag('cartoon', null); - $this->assertEquals(3, count($res)); - $this->assertNotContains('cartoon', $linkDB[4]['tags']); - } - - /** - * Test linksCountPerTag all tags without filter. - * Equal occurrences should be sorted alphabetically. - */ - public function testCountLinkPerTagAllNoFilter() - { - $expected = [ - 'web' => 4, - 'cartoon' => 3, - 'dev' => 2, - 'gnu' => 2, - 'hashtag' => 2, - 'sTuff' => 2, - '-exclude' => 1, - '.hidden' => 1, - 'Mercurial' => 1, - 'css' => 1, - 'free' => 1, - 'html' => 1, - 'media' => 1, - 'samba' => 1, - 'software' => 1, - 'stallman' => 1, - 'tag1' => 1, - 'tag2' => 1, - 'tag3' => 1, - 'tag4' => 1, - 'ut' => 1, - 'w3c' => 1, - ]; - $tags = self::$privateLinkDB->linksCountPerTag(); - - $this->assertEquals($expected, $tags, var_export($tags, true)); - } - - /** - * Test linksCountPerTag all tags with filter. - * Equal occurrences should be sorted alphabetically. - */ - public function testCountLinkPerTagAllWithFilter() - { - $expected = [ - 'gnu' => 2, - 'hashtag' => 2, - '-exclude' => 1, - '.hidden' => 1, - 'free' => 1, - 'media' => 1, - 'software' => 1, - 'stallman' => 1, - 'stuff' => 1, - 'web' => 1, - ]; - $tags = self::$privateLinkDB->linksCountPerTag(['gnu']); - - $this->assertEquals($expected, $tags, var_export($tags, true)); - } - - /** - * Test linksCountPerTag public tags with filter. - * Equal occurrences should be sorted alphabetically. - */ - public function testCountLinkPerTagPublicWithFilter() - { - $expected = [ - 'gnu' => 2, - 'hashtag' => 2, - '-exclude' => 1, - '.hidden' => 1, - 'free' => 1, - 'media' => 1, - 'software' => 1, - 'stallman' => 1, - 'stuff' => 1, - 'web' => 1, - ]; - $tags = self::$privateLinkDB->linksCountPerTag(['gnu'], 'public'); - - $this->assertEquals($expected, $tags, var_export($tags, true)); - } - - /** - * Test linksCountPerTag public tags with filter. - * Equal occurrences should be sorted alphabetically. - */ - public function testCountLinkPerTagPrivateWithFilter() - { - $expected = [ - 'cartoon' => 1, - 'dev' => 1, - 'tag1' => 1, - 'tag2' => 1, - 'tag3' => 1, - 'tag4' => 1, - ]; - $tags = self::$privateLinkDB->linksCountPerTag(['dev'], 'private'); - - $this->assertEquals($expected, $tags, var_export($tags, true)); - } -} diff --git a/tests/LinkFilterTest.php b/tests/LinkFilterTest.php index eb54c359..db28b1c4 100644 --- a/tests/LinkFilterTest.php +++ b/tests/LinkFilterTest.php @@ -1,5 +1,7 @@ container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['history'] = null; $this->controller = new Info($this->container); diff --git a/tests/api/controllers/links/DeleteLinkTest.php b/tests/api/controllers/links/DeleteLinkTest.php index 07371e7a..adca9a4e 100644 --- a/tests/api/controllers/links/DeleteLinkTest.php +++ b/tests/api/controllers/links/DeleteLinkTest.php @@ -32,7 +32,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \LinkDB instance. + * @var \Shaarli\Bookmark\LinkDB instance. */ protected $linkDB; @@ -59,7 +59,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new \Shaarli\History(self::$testHistory); @@ -96,7 +96,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->assertFalse(isset($this->linkDB[$id])); $historyEntry = $this->history->getHistory()[0]; diff --git a/tests/api/controllers/links/GetLinkIdTest.php b/tests/api/controllers/links/GetLinkIdTest.php index 57528d5a..bf58000b 100644 --- a/tests/api/controllers/links/GetLinkIdTest.php +++ b/tests/api/controllers/links/GetLinkIdTest.php @@ -61,7 +61,7 @@ class GetLinkIdTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['history'] = null; $this->controller = new Links($this->container); @@ -108,7 +108,7 @@ class GetLinkIdTest extends \PHPUnit_Framework_TestCase $this->assertEquals('sTuff', $data['tags'][0]); $this->assertEquals(false, $data['private']); $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), $data['created'] ); $this->assertEmpty($data['updated']); diff --git a/tests/api/controllers/links/GetLinksTest.php b/tests/api/controllers/links/GetLinksTest.php index 64f02774..1008d7b2 100644 --- a/tests/api/controllers/links/GetLinksTest.php +++ b/tests/api/controllers/links/GetLinksTest.php @@ -60,7 +60,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['history'] = null; $this->controller = new Links($this->container); @@ -114,7 +114,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase $this->assertEquals('sTuff', $first['tags'][0]); $this->assertEquals(false, $first['private']); $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), $first['created'] ); $this->assertEmpty($first['updated']); @@ -125,7 +125,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase // Update date $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20160803_093033')->format(\DateTime::ATOM), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20160803_093033')->format(\DateTime::ATOM), $link['updated'] ); } diff --git a/tests/api/controllers/links/PostLinkTest.php b/tests/api/controllers/links/PostLinkTest.php index a73f443c..ade07eeb 100644 --- a/tests/api/controllers/links/PostLinkTest.php +++ b/tests/api/controllers/links/PostLinkTest.php @@ -74,7 +74,7 @@ class PostLinkTest extends TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['history'] = new \Shaarli\History(self::$testHistory); $this->controller = new Links($this->container); @@ -210,11 +210,11 @@ class PostLinkTest extends TestCase $this->assertEquals(['gnu', 'media', 'web', '.hidden', 'hashtag'], $data['tags']); $this->assertEquals(false, $data['private']); $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130614_184135'), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20130614_184135'), \DateTime::createFromFormat(\DateTime::ATOM, $data['created']) ); $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130615_184230'), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20130615_184230'), \DateTime::createFromFormat(\DateTime::ATOM, $data['updated']) ); } diff --git a/tests/api/controllers/links/PutLinkTest.php b/tests/api/controllers/links/PutLinkTest.php index 3bb4d43f..eb6c7955 100644 --- a/tests/api/controllers/links/PutLinkTest.php +++ b/tests/api/controllers/links/PutLinkTest.php @@ -66,7 +66,7 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['history'] = new \Shaarli\History(self::$testHistory); $this->controller = new Links($this->container); @@ -198,11 +198,11 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase $this->assertEquals(['gnu', 'media', 'web', '.hidden', 'hashtag'], $data['tags']); $this->assertEquals(false, $data['private']); $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130614_184135'), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20130614_184135'), \DateTime::createFromFormat(\DateTime::ATOM, $data['created']) ); $this->assertEquals( - \DateTime::createFromFormat(\LinkDB::LINK_DATE_FORMAT, '20130615_184230'), + \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20130615_184230'), \DateTime::createFromFormat(\DateTime::ATOM, $data['updated']) ); } diff --git a/tests/api/controllers/tags/DeleteTagTest.php b/tests/api/controllers/tags/DeleteTagTest.php index a1e419cd..02803ba2 100644 --- a/tests/api/controllers/tags/DeleteTagTest.php +++ b/tests/api/controllers/tags/DeleteTagTest.php @@ -32,7 +32,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \LinkDB instance. + * @var \Shaarli\Bookmark\LinkDB instance. */ protected $linkDB; @@ -59,7 +59,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new \Shaarli\History(self::$testHistory); @@ -97,7 +97,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $tags = $this->linkDB->linksCountPerTag(); $this->assertFalse(isset($tags[$tagName])); @@ -131,7 +131,7 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $tags = $this->linkDB->linksCountPerTag(); $this->assertFalse(isset($tags[$tagName])); $this->assertTrue($tags[strtolower($tagName)] > 0); diff --git a/tests/api/controllers/tags/GetTagNameTest.php b/tests/api/controllers/tags/GetTagNameTest.php index afac228e..8e0feccd 100644 --- a/tests/api/controllers/tags/GetTagNameTest.php +++ b/tests/api/controllers/tags/GetTagNameTest.php @@ -59,7 +59,7 @@ class GetTagNameTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['history'] = null; $this->controller = new Tags($this->container); diff --git a/tests/api/controllers/tags/GetTagsTest.php b/tests/api/controllers/tags/GetTagsTest.php index 3fab31b0..f071bfa8 100644 --- a/tests/api/controllers/tags/GetTagsTest.php +++ b/tests/api/controllers/tags/GetTagsTest.php @@ -38,7 +38,7 @@ class GetTagsTest extends \PHPUnit_Framework_TestCase protected $container; /** - * @var \LinkDB instance. + * @var \Shaarli\Bookmark\LinkDB instance. */ protected $linkDB; @@ -63,7 +63,7 @@ class GetTagsTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['db'] = $this->linkDB; $this->container['history'] = null; diff --git a/tests/api/controllers/tags/PutTagTest.php b/tests/api/controllers/tags/PutTagTest.php index c45fa722..d8c0fec8 100644 --- a/tests/api/controllers/tags/PutTagTest.php +++ b/tests/api/controllers/tags/PutTagTest.php @@ -43,7 +43,7 @@ class PutTagTest extends \PHPUnit_Framework_TestCase protected $container; /** - * @var \LinkDB instance. + * @var \Shaarli\Bookmark\LinkDB instance. */ protected $linkDB; @@ -72,7 +72,7 @@ class PutTagTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->linkDB = new \LinkDB(self::$testDatastore, true, false); + $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); $this->container['db'] = $this->linkDB; $this->container['history'] = $this->history; diff --git a/tests/bookmark/LinkDBTest.php b/tests/bookmark/LinkDBTest.php new file mode 100644 index 00000000..f18a3155 --- /dev/null +++ b/tests/bookmark/LinkDBTest.php @@ -0,0 +1,653 @@ +write(self::$testDatastore); + + self::$publicLinkDB = new LinkDB(self::$testDatastore, false, false); + self::$privateLinkDB = new LinkDB(self::$testDatastore, true, false); + } + + /** + * Resets test data for each test + */ + protected function setUp() + { + if (file_exists(self::$testDatastore)) { + unlink(self::$testDatastore); + } + } + + /** + * Allows to test LinkDB's private methods + * + * @see + * https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html + * http://stackoverflow.com/a/2798203 + */ + protected static function getMethod($name) + { + $class = new ReflectionClass('Shaarli\Bookmark\LinkDB'); + $method = $class->getMethod($name); + $method->setAccessible(true); + return $method; + } + + /** + * Instantiate LinkDB objects - logged in user + */ + public function testConstructLoggedIn() + { + new LinkDB(self::$testDatastore, true, false); + $this->assertFileExists(self::$testDatastore); + } + + /** + * Instantiate LinkDB objects - logged out or public instance + */ + public function testConstructLoggedOut() + { + new LinkDB(self::$testDatastore, false, false); + $this->assertFileExists(self::$testDatastore); + } + + /** + * Attempt to instantiate a LinkDB whereas the datastore is not writable + * + * @expectedException Shaarli\Exceptions\IOException + * @expectedExceptionMessageRegExp /Error accessing "null"/ + */ + public function testConstructDatastoreNotWriteable() + { + new LinkDB('null/store.db', false, false); + } + + /** + * The DB doesn't exist, ensure it is created with dummy content + */ + public function testCheckDBNew() + { + $linkDB = new LinkDB(self::$testDatastore, false, false); + unlink(self::$testDatastore); + $this->assertFileNotExists(self::$testDatastore); + + $checkDB = self::getMethod('check'); + $checkDB->invokeArgs($linkDB, array()); + $this->assertFileExists(self::$testDatastore); + + // ensure the correct data has been written + $this->assertGreaterThan(0, filesize(self::$testDatastore)); + } + + /** + * The DB exists, don't do anything + */ + public function testCheckDBLoad() + { + $linkDB = new LinkDB(self::$testDatastore, false, false); + $datastoreSize = filesize(self::$testDatastore); + $this->assertGreaterThan(0, $datastoreSize); + + $checkDB = self::getMethod('check'); + $checkDB->invokeArgs($linkDB, array()); + + // ensure the datastore is left unmodified + $this->assertEquals( + $datastoreSize, + filesize(self::$testDatastore) + ); + } + + /** + * Load an empty DB + */ + public function testReadEmptyDB() + { + file_put_contents(self::$testDatastore, ''); + $emptyDB = new LinkDB(self::$testDatastore, false, false); + $this->assertEquals(0, sizeof($emptyDB)); + $this->assertEquals(0, count($emptyDB)); + } + + /** + * Load public links from the DB + */ + public function testReadPublicDB() + { + $this->assertEquals( + self::$refDB->countPublicLinks(), + sizeof(self::$publicLinkDB) + ); + } + + /** + * Load public and private links from the DB + */ + public function testReadPrivateDB() + { + $this->assertEquals( + self::$refDB->countLinks(), + sizeof(self::$privateLinkDB) + ); + } + + /** + * Save the links to the DB + */ + public function testSave() + { + $testDB = new LinkDB(self::$testDatastore, true, false); + $dbSize = sizeof($testDB); + + $link = array( + 'id' => 42, + 'title' => 'an additional link', + 'url' => 'http://dum.my', + 'description' => 'One more', + 'private' => 0, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150518_190000'), + 'tags' => 'unit test' + ); + $testDB[$link['id']] = $link; + $testDB->save('tests'); + + $testDB = new LinkDB(self::$testDatastore, true, false); + $this->assertEquals($dbSize + 1, sizeof($testDB)); + } + + /** + * Count existing links + */ + public function testCount() + { + $this->assertEquals( + self::$refDB->countPublicLinks(), + self::$publicLinkDB->count() + ); + $this->assertEquals( + self::$refDB->countLinks(), + self::$privateLinkDB->count() + ); + } + + /** + * Count existing links - public links hidden + */ + public function testCountHiddenPublic() + { + $linkDB = new LinkDB(self::$testDatastore, false, true); + + $this->assertEquals( + 0, + $linkDB->count() + ); + $this->assertEquals( + 0, + $linkDB->count() + ); + } + + /** + * List the days for which links have been posted + */ + public function testDays() + { + $this->assertEquals( + array('20100309', '20100310', '20121206', '20121207', '20130614', '20150310'), + self::$publicLinkDB->days() + ); + + $this->assertEquals( + array('20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'), + self::$privateLinkDB->days() + ); + } + + /** + * The URL corresponds to an existing entry in the DB + */ + public function testGetKnownLinkFromURL() + { + $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/'); + + $this->assertNotEquals(false, $link); + $this->assertContains( + 'A free software media publishing platform', + $link['description'] + ); + } + + /** + * The URL is not in the DB + */ + public function testGetUnknownLinkFromURL() + { + $this->assertEquals( + false, + self::$publicLinkDB->getLinkFromUrl('http://dev.null') + ); + } + + /** + * Lists all tags + */ + public function testAllTags() + { + $this->assertEquals( + array( + 'web' => 3, + 'cartoon' => 2, + 'gnu' => 2, + 'dev' => 1, + 'samba' => 1, + 'media' => 1, + 'software' => 1, + 'stallman' => 1, + 'free' => 1, + '-exclude' => 1, + 'hashtag' => 2, + // The DB contains a link with `sTuff` and another one with `stuff` tag. + // They need to be grouped with the first case found - order by date DESC: `sTuff`. + 'sTuff' => 2, + 'ut' => 1, + ), + self::$publicLinkDB->linksCountPerTag() + ); + + $this->assertEquals( + array( + 'web' => 4, + 'cartoon' => 3, + 'gnu' => 2, + 'dev' => 2, + 'samba' => 1, + 'media' => 1, + 'software' => 1, + 'stallman' => 1, + 'free' => 1, + 'html' => 1, + 'w3c' => 1, + 'css' => 1, + 'Mercurial' => 1, + 'sTuff' => 2, + '-exclude' => 1, + '.hidden' => 1, + 'hashtag' => 2, + 'tag1' => 1, + 'tag2' => 1, + 'tag3' => 1, + 'tag4' => 1, + 'ut' => 1, + ), + self::$privateLinkDB->linksCountPerTag() + ); + $this->assertEquals( + array( + 'web' => 4, + 'cartoon' => 2, + 'gnu' => 1, + 'dev' => 1, + 'samba' => 1, + 'media' => 1, + 'html' => 1, + 'w3c' => 1, + 'css' => 1, + 'Mercurial' => 1, + '.hidden' => 1, + 'hashtag' => 1, + ), + self::$privateLinkDB->linksCountPerTag(['web']) + ); + $this->assertEquals( + array( + 'web' => 1, + 'html' => 1, + 'w3c' => 1, + 'css' => 1, + 'Mercurial' => 1, + ), + self::$privateLinkDB->linksCountPerTag(['web'], 'private') + ); + } + + /** + * Test real_url without redirector. + */ + public function testLinkRealUrlWithoutRedirector() + { + $db = new LinkDB(self::$testDatastore, false, false); + foreach ($db as $link) { + $this->assertEquals($link['url'], $link['real_url']); + } + } + + /** + * Test real_url with redirector. + */ + public function testLinkRealUrlWithRedirector() + { + $redirector = 'http://redirector.to?'; + $db = new LinkDB(self::$testDatastore, false, false, $redirector); + foreach ($db as $link) { + $this->assertStringStartsWith($redirector, $link['real_url']); + $this->assertNotFalse(strpos($link['real_url'], urlencode('://'))); + } + + $db = new LinkDB(self::$testDatastore, false, false, $redirector, false); + foreach ($db as $link) { + $this->assertStringStartsWith($redirector, $link['real_url']); + $this->assertFalse(strpos($link['real_url'], urlencode('://'))); + } + } + + /** + * Test filter with string. + */ + public function testFilterString() + { + $tags = 'dev cartoon'; + $request = array('searchtags' => $tags); + $this->assertEquals( + 2, + count(self::$privateLinkDB->filterSearch($request, true, false)) + ); + } + + /** + * Test filter with string. + */ + public function testFilterArray() + { + $tags = array('dev', 'cartoon'); + $request = array('searchtags' => $tags); + $this->assertEquals( + 2, + count(self::$privateLinkDB->filterSearch($request, true, false)) + ); + } + + /** + * Test hidden tags feature: + * tags starting with a dot '.' are only visible when logged in. + */ + public function testHiddenTags() + { + $tags = '.hidden'; + $request = array('searchtags' => $tags); + $this->assertEquals( + 1, + count(self::$privateLinkDB->filterSearch($request, true, false)) + ); + + $this->assertEquals( + 0, + count(self::$publicLinkDB->filterSearch($request, true, false)) + ); + } + + /** + * Test filterHash() with a valid smallhash. + */ + public function testFilterHashValid() + { + $request = smallHash('20150310_114651'); + $this->assertEquals( + 1, + count(self::$publicLinkDB->filterHash($request)) + ); + $request = smallHash('20150310_114633' . 8); + $this->assertEquals( + 1, + count(self::$publicLinkDB->filterHash($request)) + ); + } + + /** + * Test filterHash() with an invalid smallhash. + * + * @expectedException LinkNotFoundException + */ + public function testFilterHashInValid1() + { + $request = 'blabla'; + self::$publicLinkDB->filterHash($request); + } + + /** + * Test filterHash() with an empty smallhash. + * + * @expectedException LinkNotFoundException + */ + public function testFilterHashInValid() + { + self::$publicLinkDB->filterHash(''); + } + + /** + * Test reorder with asc/desc parameter. + */ + public function testReorderLinksDesc() + { + self::$privateLinkDB->reorder('ASC'); + $stickyIds = [11, 10]; + $standardIds = [42, 4, 9, 1, 0, 7, 6, 8, 41]; + $linkIds = array_merge($stickyIds, $standardIds); + $cpt = 0; + foreach (self::$privateLinkDB as $key => $value) { + $this->assertEquals($linkIds[$cpt++], $key); + } + self::$privateLinkDB->reorder('DESC'); + $linkIds = array_merge(array_reverse($stickyIds), array_reverse($standardIds)); + $cpt = 0; + foreach (self::$privateLinkDB as $key => $value) { + $this->assertEquals($linkIds[$cpt++], $key); + } + } + + /** + * Test rename tag with a valid value present in multiple links + */ + public function testRenameTagMultiple() + { + self::$refDB->write(self::$testDatastore); + $linkDB = new LinkDB(self::$testDatastore, true, false); + + $res = $linkDB->renameTag('cartoon', 'Taz'); + $this->assertEquals(3, count($res)); + $this->assertContains(' Taz ', $linkDB[4]['tags']); + $this->assertContains(' Taz ', $linkDB[1]['tags']); + $this->assertContains(' Taz ', $linkDB[0]['tags']); + } + + /** + * Test rename tag with a valid value + */ + public function testRenameTagCaseSensitive() + { + self::$refDB->write(self::$testDatastore); + $linkDB = new LinkDB(self::$testDatastore, true, false, ''); + + $res = $linkDB->renameTag('sTuff', 'Taz'); + $this->assertEquals(1, count($res)); + $this->assertEquals('Taz', $linkDB[41]['tags']); + } + + /** + * Test rename tag with invalid values + */ + public function testRenameTagInvalid() + { + $linkDB = new LinkDB(self::$testDatastore, false, false); + + $this->assertFalse($linkDB->renameTag('', 'test')); + $this->assertFalse($linkDB->renameTag('', '')); + // tag non existent + $this->assertEquals([], $linkDB->renameTag('test', '')); + $this->assertEquals([], $linkDB->renameTag('test', 'retest')); + } + + /** + * Test delete tag with a valid value + */ + public function testDeleteTag() + { + self::$refDB->write(self::$testDatastore); + $linkDB = new LinkDB(self::$testDatastore, true, false); + + $res = $linkDB->renameTag('cartoon', null); + $this->assertEquals(3, count($res)); + $this->assertNotContains('cartoon', $linkDB[4]['tags']); + } + + /** + * Test linksCountPerTag all tags without filter. + * Equal occurrences should be sorted alphabetically. + */ + public function testCountLinkPerTagAllNoFilter() + { + $expected = [ + 'web' => 4, + 'cartoon' => 3, + 'dev' => 2, + 'gnu' => 2, + 'hashtag' => 2, + 'sTuff' => 2, + '-exclude' => 1, + '.hidden' => 1, + 'Mercurial' => 1, + 'css' => 1, + 'free' => 1, + 'html' => 1, + 'media' => 1, + 'samba' => 1, + 'software' => 1, + 'stallman' => 1, + 'tag1' => 1, + 'tag2' => 1, + 'tag3' => 1, + 'tag4' => 1, + 'ut' => 1, + 'w3c' => 1, + ]; + $tags = self::$privateLinkDB->linksCountPerTag(); + + $this->assertEquals($expected, $tags, var_export($tags, true)); + } + + /** + * Test linksCountPerTag all tags with filter. + * Equal occurrences should be sorted alphabetically. + */ + public function testCountLinkPerTagAllWithFilter() + { + $expected = [ + 'gnu' => 2, + 'hashtag' => 2, + '-exclude' => 1, + '.hidden' => 1, + 'free' => 1, + 'media' => 1, + 'software' => 1, + 'stallman' => 1, + 'stuff' => 1, + 'web' => 1, + ]; + $tags = self::$privateLinkDB->linksCountPerTag(['gnu']); + + $this->assertEquals($expected, $tags, var_export($tags, true)); + } + + /** + * Test linksCountPerTag public tags with filter. + * Equal occurrences should be sorted alphabetically. + */ + public function testCountLinkPerTagPublicWithFilter() + { + $expected = [ + 'gnu' => 2, + 'hashtag' => 2, + '-exclude' => 1, + '.hidden' => 1, + 'free' => 1, + 'media' => 1, + 'software' => 1, + 'stallman' => 1, + 'stuff' => 1, + 'web' => 1, + ]; + $tags = self::$privateLinkDB->linksCountPerTag(['gnu'], 'public'); + + $this->assertEquals($expected, $tags, var_export($tags, true)); + } + + /** + * Test linksCountPerTag public tags with filter. + * Equal occurrences should be sorted alphabetically. + */ + public function testCountLinkPerTagPrivateWithFilter() + { + $expected = [ + 'cartoon' => 1, + 'dev' => 1, + 'tag1' => 1, + 'tag2' => 1, + 'tag3' => 1, + 'tag4' => 1, + ]; + $tags = self::$privateLinkDB->linksCountPerTag(['dev'], 'private'); + + $this->assertEquals($expected, $tags, var_export($tags, true)); + } +} diff --git a/tests/feed/FeedBuilderTest.php b/tests/feed/FeedBuilderTest.php index 1fdbc60e..88d1c3ed 100644 --- a/tests/feed/FeedBuilderTest.php +++ b/tests/feed/FeedBuilderTest.php @@ -3,11 +3,9 @@ namespace Shaarli\Feed; use DateTime; -use LinkDB; +use Shaarli\Bookmark\LinkDB; use ReferenceLinkDB; -require_once 'application/LinkDB.php'; - /** * FeedBuilderTest class. * diff --git a/tests/http/UrlTest.php b/tests/http/UrlTest.php index 342b78a4..ae92f73a 100644 --- a/tests/http/UrlTest.php +++ b/tests/http/UrlTest.php @@ -5,7 +5,6 @@ namespace Shaarli\Http; - /** * Unitary tests for URL utilities */ diff --git a/tests/plugins/PluginIssoTest.php b/tests/plugins/PluginIssoTest.php index 2c9efbcd..f5fa1daa 100644 --- a/tests/plugins/PluginIssoTest.php +++ b/tests/plugins/PluginIssoTest.php @@ -1,4 +1,6 @@ Date: Mon, 3 Dec 2018 01:22:45 +0100 Subject: namespacing: \Shaarli\Bookmark\LinkFilter Signed-off-by: VirtualTam --- application/LinkFilter.php | 455 ------------------ application/Updater.php | 1 + application/bookmark/LinkDB.php | 26 +- application/bookmark/LinkFilter.php | 449 ++++++++++++++++++ .../bookmark/exception/LinkNotFoundException.php | 15 + composer.json | 1 + index.php | 2 +- tests/LinkFilterTest.php | 502 -------------------- tests/bookmark/LinkDBTest.php | 6 +- tests/bookmark/LinkFilterTest.php | 507 +++++++++++++++++++++ 10 files changed, 990 insertions(+), 974 deletions(-) delete mode 100644 application/LinkFilter.php create mode 100644 application/bookmark/LinkFilter.php create mode 100644 application/bookmark/exception/LinkNotFoundException.php delete mode 100644 tests/LinkFilterTest.php create mode 100644 tests/bookmark/LinkFilterTest.php diff --git a/application/LinkFilter.php b/application/LinkFilter.php deleted file mode 100644 index 91c79905..00000000 --- a/application/LinkFilter.php +++ /dev/null @@ -1,455 +0,0 @@ -links = $links; - } - - /** - * Filter links according to parameters. - * - * @param string $type Type of filter (eg. tags, permalink, etc.). - * @param mixed $request Filter content. - * @param bool $casesensitive Optional: Perform case sensitive filter if true. - * @param string $visibility Optional: return only all/private/public links - * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG - * - * @return array filtered link list. - */ - public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) - { - if (! in_array($visibility, ['all', 'public', 'private'])) { - $visibility = 'all'; - } - - switch ($type) { - case self::$FILTER_HASH: - return $this->filterSmallHash($request); - case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" - $noRequest = empty($request) || (empty($request[0]) && empty($request[1])); - if ($noRequest) { - if ($untaggedonly) { - return $this->filterUntagged($visibility); - } - return $this->noFilter($visibility); - } - if ($untaggedonly) { - $filtered = $this->filterUntagged($visibility); - } else { - $filtered = $this->links; - } - if (!empty($request[0])) { - $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); - } - if (!empty($request[1])) { - $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); - } - return $filtered; - case self::$FILTER_TEXT: - return $this->filterFulltext($request, $visibility); - case self::$FILTER_TAG: - if ($untaggedonly) { - return $this->filterUntagged($visibility); - } else { - return $this->filterTags($request, $casesensitive, $visibility); - } - case self::$FILTER_DAY: - return $this->filterDay($request); - default: - return $this->noFilter($visibility); - } - } - - /** - * Unknown filter, but handle private only. - * - * @param string $visibility Optional: return only all/private/public links - * - * @return array filtered links. - */ - private function noFilter($visibility = 'all') - { - if ($visibility === 'all') { - return $this->links; - } - - $out = array(); - foreach ($this->links as $key => $value) { - if ($value['private'] && $visibility === 'private') { - $out[$key] = $value; - } elseif (! $value['private'] && $visibility === 'public') { - $out[$key] = $value; - } - } - - return $out; - } - - /** - * Returns the shaare corresponding to a smallHash. - * - * @param string $smallHash permalink hash. - * - * @return array $filtered array containing permalink data. - * - * @throws LinkNotFoundException if the smallhash doesn't match any link. - */ - private function filterSmallHash($smallHash) - { - $filtered = array(); - foreach ($this->links as $key => $l) { - if ($smallHash == $l['shorturl']) { - // Yes, this is ugly and slow - $filtered[$key] = $l; - return $filtered; - } - } - - if (empty($filtered)) { - throw new LinkNotFoundException(); - } - - return $filtered; - } - - /** - * Returns the list of links corresponding to a full-text search - * - * Searches: - * - in the URLs, title and description; - * - are case-insensitive; - * - terms surrounded by quotes " are exact terms search. - * - terms starting with a dash - are excluded (except exact terms). - * - * Example: - * print_r($mydb->filterFulltext('hollandais')); - * - * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') - * - allows to perform searches on Unicode text - * - see https://github.com/shaarli/Shaarli/issues/75 for examples - * - * @param string $searchterms search query. - * @param string $visibility Optional: return only all/private/public links. - * - * @return array search results. - */ - private function filterFulltext($searchterms, $visibility = 'all') - { - if (empty($searchterms)) { - return $this->noFilter($visibility); - } - - $filtered = array(); - $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); - $exactRegex = '/"([^"]+)"/'; - // Retrieve exact search terms. - preg_match_all($exactRegex, $search, $exactSearch); - $exactSearch = array_values(array_filter($exactSearch[1])); - - // Remove exact search terms to get AND terms search. - $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search))); - $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); - - // Filter excluding terms and update andSearch. - $excludeSearch = array(); - $andSearch = array(); - foreach ($explodedSearchAnd as $needle) { - if ($needle[0] == '-' && strlen($needle) > 1) { - $excludeSearch[] = substr($needle, 1); - } else { - $andSearch[] = $needle; - } - } - - $keys = array('title', 'description', 'url', 'tags'); - - // Iterate over every stored link. - foreach ($this->links as $id => $link) { - // ignore non private links when 'privatonly' is on. - if ($visibility !== 'all') { - if (! $link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - - // Concatenate link fields to search across fields. - // Adds a '\' separator for exact search terms. - $content = ''; - foreach ($keys as $key) { - $content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\'; - } - - // Be optimistic - $found = true; - - // First, we look for exact term search - for ($i = 0; $i < count($exactSearch) && $found; $i++) { - $found = strpos($content, $exactSearch[$i]) !== false; - } - - // Iterate over keywords, if keyword is not found, - // no need to check for the others. We want all or nothing. - for ($i = 0; $i < count($andSearch) && $found; $i++) { - $found = strpos($content, $andSearch[$i]) !== false; - } - - // Exclude terms. - for ($i = 0; $i < count($excludeSearch) && $found; $i++) { - $found = strpos($content, $excludeSearch[$i]) === false; - } - - if ($found) { - $filtered[$id] = $link; - } - } - - return $filtered; - } - - /** - * generate a regex fragment out of a tag - * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard - * @return string generated regex fragment - */ - private static function tag2regex($tag) - { - $len = strlen($tag); - if (!$len || $tag === "-" || $tag === "*") { - // nothing to search, return empty regex - return ''; - } - if ($tag[0] === "-") { - // query is negated - $i = 1; // use offset to start after '-' character - $regex = '(?!'; // create negative lookahead - } else { - $i = 0; // start at first character - $regex = '(?='; // use positive lookahead - } - $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning - // iterate over string, separating it into placeholder and content - for (; $i < $len; $i++) { - if ($tag[$i] === '*') { - // placeholder found - $regex .= '[^ ]*?'; - } else { - // regular characters - $offset = strpos($tag, '*', $i); - if ($offset === false) { - // no placeholder found, set offset to end of string - $offset = $len; - } - // subtract one, as we want to get before the placeholder or end of string - $offset -= 1; - // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. - $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); - // move $i on - $i = $offset; - } - } - $regex .= '(?:$| ))'; // after the tag may only be a space or the end - return $regex; - } - - /** - * Returns the list of links associated with a given list of tags - * - * You can specify one or more tags, separated by space or a comma, e.g. - * print_r($mydb->filterTags('linux programming')); - * - * @param string $tags list of tags separated by commas or blank spaces. - * @param bool $casesensitive ignore case if false. - * @param string $visibility Optional: return only all/private/public links. - * - * @return array filtered links. - */ - public function filterTags($tags, $casesensitive = false, $visibility = 'all') - { - // get single tags (we may get passed an array, even though the docs say different) - $inputTags = $tags; - if (!is_array($tags)) { - // we got an input string, split tags - $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); - } - - if (!count($inputTags)) { - // no input tags - return $this->noFilter($visibility); - } - - // build regex from all tags - $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; - if (!$casesensitive) { - // make regex case insensitive - $re .= 'i'; - } - - // create resulting array - $filtered = array(); - - // iterate over each link - foreach ($this->links as $key => $link) { - // check level of visibility - // ignore non private links when 'privateonly' is on. - if ($visibility !== 'all') { - if (! $link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - $search = $link['tags']; // build search string, start with tags of current link - if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { - // description given and at least one possible tag found - $descTags = array(); - // find all tags in the form of #tag in the description - preg_match_all( - '/(?links as $key => $link) { - if ($visibility !== 'all') { - if (! $link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - - if (empty(trim($link['tags']))) { - $filtered[$key] = $link; - } - } - - return $filtered; - } - - /** - * Returns the list of articles for a given day, chronologically sorted - * - * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. - * print_r($mydb->filterDay('20120125')); - * - * @param string $day day to filter. - * - * @return array all link matching given day. - * - * @throws Exception if date format is invalid. - */ - public function filterDay($day) - { - if (! checkDateFormat('Ymd', $day)) { - throw new Exception('Invalid date format'); - } - - $filtered = array(); - foreach ($this->links as $key => $l) { - if ($l['created']->format('Ymd') == $day) { - $filtered[$key] = $l; - } - } - - // sort by date ASC - return array_reverse($filtered, true); - } - - /** - * Convert a list of tags (str) to an array. Also - * - handle case sensitivity. - * - accepts spaces commas as separator. - * - * @param string $tags string containing a list of tags. - * @param bool $casesensitive will convert everything to lowercase if false. - * - * @return array filtered tags string. - */ - public static function tagsStrToArray($tags, $casesensitive) - { - // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) - $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); - $tagsOut = str_replace(',', ' ', $tagsOut); - - return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); - } -} - -class LinkNotFoundException extends Exception -{ - /** - * LinkNotFoundException constructor. - */ - public function __construct() - { - $this->message = t('The link you are trying to reach does not exist or has been deleted.'); - } -} diff --git a/application/Updater.php b/application/Updater.php index 043ecf68..ca05ecc2 100644 --- a/application/Updater.php +++ b/application/Updater.php @@ -1,6 +1,7 @@ linksCount */ @@ -500,7 +500,7 @@ You use the community supported version of the original Shaarli project, by Seba * Rename or delete a tag across all links. * * @param string $from Tag to rename - * @param string $to New tag. If none is provided, the from tag will be deleted + * @param string $to New tag. If none is provided, the from tag will be deleted * * @return array|bool List of altered links or false on error */ diff --git a/application/bookmark/LinkFilter.php b/application/bookmark/LinkFilter.php new file mode 100644 index 00000000..9b966307 --- /dev/null +++ b/application/bookmark/LinkFilter.php @@ -0,0 +1,449 @@ +links = $links; + } + + /** + * Filter links according to parameters. + * + * @param string $type Type of filter (eg. tags, permalink, etc.). + * @param mixed $request Filter content. + * @param bool $casesensitive Optional: Perform case sensitive filter if true. + * @param string $visibility Optional: return only all/private/public links + * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG + * + * @return array filtered link list. + */ + public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) + { + if (!in_array($visibility, ['all', 'public', 'private'])) { + $visibility = 'all'; + } + + switch ($type) { + case self::$FILTER_HASH: + return $this->filterSmallHash($request); + case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" + $noRequest = empty($request) || (empty($request[0]) && empty($request[1])); + if ($noRequest) { + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } + return $this->noFilter($visibility); + } + if ($untaggedonly) { + $filtered = $this->filterUntagged($visibility); + } else { + $filtered = $this->links; + } + if (!empty($request[0])) { + $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + } + if (!empty($request[1])) { + $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); + } + return $filtered; + case self::$FILTER_TEXT: + return $this->filterFulltext($request, $visibility); + case self::$FILTER_TAG: + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } else { + return $this->filterTags($request, $casesensitive, $visibility); + } + case self::$FILTER_DAY: + return $this->filterDay($request); + default: + return $this->noFilter($visibility); + } + } + + /** + * Unknown filter, but handle private only. + * + * @param string $visibility Optional: return only all/private/public links + * + * @return array filtered links. + */ + private function noFilter($visibility = 'all') + { + if ($visibility === 'all') { + return $this->links; + } + + $out = array(); + foreach ($this->links as $key => $value) { + if ($value['private'] && $visibility === 'private') { + $out[$key] = $value; + } elseif (!$value['private'] && $visibility === 'public') { + $out[$key] = $value; + } + } + + return $out; + } + + /** + * Returns the shaare corresponding to a smallHash. + * + * @param string $smallHash permalink hash. + * + * @return array $filtered array containing permalink data. + * + * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. + */ + private function filterSmallHash($smallHash) + { + $filtered = array(); + foreach ($this->links as $key => $l) { + if ($smallHash == $l['shorturl']) { + // Yes, this is ugly and slow + $filtered[$key] = $l; + return $filtered; + } + } + + if (empty($filtered)) { + throw new LinkNotFoundException(); + } + + return $filtered; + } + + /** + * Returns the list of links corresponding to a full-text search + * + * Searches: + * - in the URLs, title and description; + * - are case-insensitive; + * - terms surrounded by quotes " are exact terms search. + * - terms starting with a dash - are excluded (except exact terms). + * + * Example: + * print_r($mydb->filterFulltext('hollandais')); + * + * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') + * - allows to perform searches on Unicode text + * - see https://github.com/shaarli/Shaarli/issues/75 for examples + * + * @param string $searchterms search query. + * @param string $visibility Optional: return only all/private/public links. + * + * @return array search results. + */ + private function filterFulltext($searchterms, $visibility = 'all') + { + if (empty($searchterms)) { + return $this->noFilter($visibility); + } + + $filtered = array(); + $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); + $exactRegex = '/"([^"]+)"/'; + // Retrieve exact search terms. + preg_match_all($exactRegex, $search, $exactSearch); + $exactSearch = array_values(array_filter($exactSearch[1])); + + // Remove exact search terms to get AND terms search. + $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search))); + $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); + + // Filter excluding terms and update andSearch. + $excludeSearch = array(); + $andSearch = array(); + foreach ($explodedSearchAnd as $needle) { + if ($needle[0] == '-' && strlen($needle) > 1) { + $excludeSearch[] = substr($needle, 1); + } else { + $andSearch[] = $needle; + } + } + + $keys = array('title', 'description', 'url', 'tags'); + + // Iterate over every stored link. + foreach ($this->links as $id => $link) { + // ignore non private links when 'privatonly' is on. + if ($visibility !== 'all') { + if (!$link['private'] && $visibility === 'private') { + continue; + } elseif ($link['private'] && $visibility === 'public') { + continue; + } + } + + // Concatenate link fields to search across fields. + // Adds a '\' separator for exact search terms. + $content = ''; + foreach ($keys as $key) { + $content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\'; + } + + // Be optimistic + $found = true; + + // First, we look for exact term search + for ($i = 0; $i < count($exactSearch) && $found; $i++) { + $found = strpos($content, $exactSearch[$i]) !== false; + } + + // Iterate over keywords, if keyword is not found, + // no need to check for the others. We want all or nothing. + for ($i = 0; $i < count($andSearch) && $found; $i++) { + $found = strpos($content, $andSearch[$i]) !== false; + } + + // Exclude terms. + for ($i = 0; $i < count($excludeSearch) && $found; $i++) { + $found = strpos($content, $excludeSearch[$i]) === false; + } + + if ($found) { + $filtered[$id] = $link; + } + } + + return $filtered; + } + + /** + * generate a regex fragment out of a tag + * + * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard + * + * @return string generated regex fragment + */ + private static function tag2regex($tag) + { + $len = strlen($tag); + if (!$len || $tag === "-" || $tag === "*") { + // nothing to search, return empty regex + return ''; + } + if ($tag[0] === "-") { + // query is negated + $i = 1; // use offset to start after '-' character + $regex = '(?!'; // create negative lookahead + } else { + $i = 0; // start at first character + $regex = '(?='; // use positive lookahead + } + $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning + // iterate over string, separating it into placeholder and content + for (; $i < $len; $i++) { + if ($tag[$i] === '*') { + // placeholder found + $regex .= '[^ ]*?'; + } else { + // regular characters + $offset = strpos($tag, '*', $i); + if ($offset === false) { + // no placeholder found, set offset to end of string + $offset = $len; + } + // subtract one, as we want to get before the placeholder or end of string + $offset -= 1; + // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. + $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); + // move $i on + $i = $offset; + } + } + $regex .= '(?:$| ))'; // after the tag may only be a space or the end + return $regex; + } + + /** + * Returns the list of links associated with a given list of tags + * + * You can specify one or more tags, separated by space or a comma, e.g. + * print_r($mydb->filterTags('linux programming')); + * + * @param string $tags list of tags separated by commas or blank spaces. + * @param bool $casesensitive ignore case if false. + * @param string $visibility Optional: return only all/private/public links. + * + * @return array filtered links. + */ + public function filterTags($tags, $casesensitive = false, $visibility = 'all') + { + // get single tags (we may get passed an array, even though the docs say different) + $inputTags = $tags; + if (!is_array($tags)) { + // we got an input string, split tags + $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); + } + + if (!count($inputTags)) { + // no input tags + return $this->noFilter($visibility); + } + + // build regex from all tags + $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; + if (!$casesensitive) { + // make regex case insensitive + $re .= 'i'; + } + + // create resulting array + $filtered = array(); + + // iterate over each link + foreach ($this->links as $key => $link) { + // check level of visibility + // ignore non private links when 'privateonly' is on. + if ($visibility !== 'all') { + if (!$link['private'] && $visibility === 'private') { + continue; + } elseif ($link['private'] && $visibility === 'public') { + continue; + } + } + $search = $link['tags']; // build search string, start with tags of current link + if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { + // description given and at least one possible tag found + $descTags = array(); + // find all tags in the form of #tag in the description + preg_match_all( + '/(?links as $key => $link) { + if ($visibility !== 'all') { + if (!$link['private'] && $visibility === 'private') { + continue; + } elseif ($link['private'] && $visibility === 'public') { + continue; + } + } + + if (empty(trim($link['tags']))) { + $filtered[$key] = $link; + } + } + + return $filtered; + } + + /** + * Returns the list of articles for a given day, chronologically sorted + * + * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. + * print_r($mydb->filterDay('20120125')); + * + * @param string $day day to filter. + * + * @return array all link matching given day. + * + * @throws Exception if date format is invalid. + */ + public function filterDay($day) + { + if (!checkDateFormat('Ymd', $day)) { + throw new Exception('Invalid date format'); + } + + $filtered = array(); + foreach ($this->links as $key => $l) { + if ($l['created']->format('Ymd') == $day) { + $filtered[$key] = $l; + } + } + + // sort by date ASC + return array_reverse($filtered, true); + } + + /** + * Convert a list of tags (str) to an array. Also + * - handle case sensitivity. + * - accepts spaces commas as separator. + * + * @param string $tags string containing a list of tags. + * @param bool $casesensitive will convert everything to lowercase if false. + * + * @return array filtered tags string. + */ + public static function tagsStrToArray($tags, $casesensitive) + { + // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) + $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); + $tagsOut = str_replace(',', ' ', $tagsOut); + + return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/LinkNotFoundException.php new file mode 100644 index 00000000..f9414428 --- /dev/null +++ b/application/bookmark/exception/LinkNotFoundException.php @@ -0,0 +1,15 @@ +message = t('The link you are trying to reach does not exist or has been deleted.'); + } +} diff --git a/composer.json b/composer.json index e8dc2eb1..7d0f96b3 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "Shaarli\\Api\\Controllers\\": "application/api/controllers", "Shaarli\\Api\\Exceptions\\": "application/api/exceptions", "Shaarli\\Bookmark\\": "application/bookmark", + "Shaarli\\Bookmark\\Exception\\": "application/bookmark/exception", "Shaarli\\Config\\": "application/config/", "Shaarli\\Config\\Exception\\": "application/config/exception", "Shaarli\\Exceptions\\": "application/exceptions", diff --git a/index.php b/index.php index b1d37a01..dbb3c6fc 100644 --- a/index.php +++ b/index.php @@ -63,7 +63,6 @@ require_once 'application/http/HttpUtils.php'; require_once 'application/http/UrlUtils.php'; require_once 'application/FileUtils.php'; require_once 'application/History.php'; -require_once 'application/LinkFilter.php'; require_once 'application/LinkUtils.php'; require_once 'application/NetscapeBookmarkUtils.php'; require_once 'application/TimeZone.php'; @@ -72,6 +71,7 @@ require_once 'application/PluginManager.php'; require_once 'application/Router.php'; require_once 'application/Updater.php'; +use \Shaarli\Bookmark\Exception\LinkNotFoundException; use \Shaarli\Bookmark\LinkDB; use \Shaarli\Config\ConfigManager; use \Shaarli\Feed\CachedPage; diff --git a/tests/LinkFilterTest.php b/tests/LinkFilterTest.php deleted file mode 100644 index db28b1c4..00000000 --- a/tests/LinkFilterTest.php +++ /dev/null @@ -1,502 +0,0 @@ -write(self::$testDatastore); - self::$linkDB = new LinkDB(self::$testDatastore, true, false); - self::$linkFilter = new LinkFilter(self::$linkDB); - } - - /** - * Blank filter. - */ - public function testFilter() - { - $this->assertEquals( - self::$refDB->countLinks(), - count(self::$linkFilter->filter('', '')) - ); - - $this->assertEquals( - self::$refDB->countLinks(), - count(self::$linkFilter->filter('', '', 'all')) - ); - - $this->assertEquals( - self::$refDB->countLinks(), - count(self::$linkFilter->filter('', '', 'randomstr')) - ); - - // Private only. - $this->assertEquals( - self::$refDB->countPrivateLinks(), - count(self::$linkFilter->filter('', '', false, 'private')) - ); - - // Public only. - $this->assertEquals( - self::$refDB->countPublicLinks(), - count(self::$linkFilter->filter('', '', false, 'public')) - ); - - $this->assertEquals( - ReferenceLinkDB::$NB_LINKS_TOTAL, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '')) - ); - - $this->assertEquals( - self::$refDB->countUntaggedLinks(), - count( - self::$linkFilter->filter( - LinkFilter::$FILTER_TAG, - /*$request=*/'', - /*$casesensitive=*/false, - /*$visibility=*/'all', - /*$untaggedonly=*/true - ) - ) - ); - - $this->assertEquals( - ReferenceLinkDB::$NB_LINKS_TOTAL, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '')) - ); - } - - /** - * Filter links using a tag - */ - public function testFilterOneTag() - { - $this->assertEquals( - 4, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false)) - ); - - $this->assertEquals( - 4, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'all')) - ); - - $this->assertEquals( - 4, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'default-blabla')) - ); - - // Private only. - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'private')) - ); - - // Public only. - $this->assertEquals( - 3, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'public')) - ); - } - - /** - * Filter links using a tag - case-sensitive - */ - public function testFilterCaseSensitiveTag() - { - $this->assertEquals( - 0, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'mercurial', true)) - ); - - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'Mercurial', true)) - ); - } - - /** - * Filter links using a tag combination - */ - public function testFilterMultipleTags() - { - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'dev cartoon', false)) - ); - } - - /** - * Filter links using a non-existent tag - */ - public function testFilterUnknownTag() - { - $this->assertEquals( - 0, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'null', false)) - ); - } - - /** - * Return links for a given day - */ - public function testFilterDay() - { - $this->assertEquals( - 4, - count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20121206')) - ); - } - - /** - * 404 - day not found - */ - public function testFilterUnknownDay() - { - $this->assertEquals( - 0, - count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '19700101')) - ); - } - - /** - * Use an invalid date format - * @expectedException Exception - * @expectedExceptionMessageRegExp /Invalid date format/ - */ - public function testFilterInvalidDayWithChars() - { - self::$linkFilter->filter(LinkFilter::$FILTER_DAY, 'Rainy day, dream away'); - } - - /** - * Use an invalid date format - * @expectedException Exception - * @expectedExceptionMessageRegExp /Invalid date format/ - */ - public function testFilterInvalidDayDigits() - { - self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20'); - } - - /** - * Retrieve a link entry with its hash - */ - public function testFilterSmallHash() - { - $links = self::$linkFilter->filter(LinkFilter::$FILTER_HASH, 'IuWvgA'); - - $this->assertEquals( - 1, - count($links) - ); - - $this->assertEquals( - 'MediaGoblin', - $links[7]['title'] - ); - } - - /** - * No link for this hash - * - * @expectedException LinkNotFoundException - */ - public function testFilterUnknownSmallHash() - { - self::$linkFilter->filter(LinkFilter::$FILTER_HASH, 'Iblaah'); - } - - /** - * Full-text search - no result found. - */ - public function testFilterFullTextNoResult() - { - $this->assertEquals( - 0, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'azertyuiop')) - ); - } - - /** - * Full-text search - result from a link's URL - */ - public function testFilterFullTextURL() - { - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'ars.userfriendly.org')) - ); - - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'ars org')) - ); - } - - /** - * Full-text search - result from a link's title only - */ - public function testFilterFullTextTitle() - { - // use miscellaneous cases - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'userfriendly -')) - ); - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'UserFriendly -')) - ); - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'uSeRFrIendlY -')) - ); - - // use miscellaneous case and offset - $this->assertEquals( - 2, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'RFrIendL')) - ); - } - - /** - * Full-text search - result from the link's description only - */ - public function testFilterFullTextDescription() - { - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'publishing media')) - ); - - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'mercurial w3c')) - ); - - $this->assertEquals( - 3, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '"free software"')) - ); - } - - /** - * Full-text search - result from the link's tags only - */ - public function testFilterFullTextTags() - { - $this->assertEquals( - 6, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web')) - ); - - $this->assertEquals( - 6, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', 'all')) - ); - - $this->assertEquals( - 6, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', 'bla')) - ); - - // Private only. - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', false, 'private')) - ); - - // Public only. - $this->assertEquals( - 5, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', false, 'public')) - ); - } - - /** - * Full-text search - result set from mixed sources - */ - public function testFilterFullTextMixed() - { - $this->assertEquals( - 3, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'free software')) - ); - } - - /** - * Full-text search - test exclusion with '-'. - */ - public function testExcludeSearch() - { - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'free -gnu')) - ); - - $this->assertEquals( - ReferenceLinkDB::$NB_LINKS_TOTAL - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '-revolution')) - ); - } - - /** - * Full-text search - test AND, exact terms and exclusion combined, across fields. - */ - public function testMultiSearch() - { - $this->assertEquals( - 2, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TEXT, - '"Free Software " stallman "read this" @website stuff' - )) - ); - - $this->assertEquals( - 1, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TEXT, - '"free software " stallman "read this" -beard @website stuff' - )) - ); - } - - /** - * Full-text search - make sure that exact search won't work across fields. - */ - public function testSearchExactTermMultiFieldsKo() - { - $this->assertEquals( - 0, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TEXT, - '"designer naming"' - )) - ); - - $this->assertEquals( - 0, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TEXT, - '"designernaming"' - )) - ); - } - - /** - * Tag search with exclusion. - */ - public function testTagFilterWithExclusion() - { - $this->assertEquals( - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'gnu -free')) - ); - - $this->assertEquals( - ReferenceLinkDB::$NB_LINKS_TOTAL - 1, - count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '-free')) - ); - } - - /** - * Test crossed search (terms + tags). - */ - public function testFilterCrossedSearch() - { - $terms = '"Free Software " stallman "read this" @website stuff'; - $tags = 'free'; - $this->assertEquals( - 1, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, - array($tags, $terms) - )) - ); - $this->assertEquals( - 2, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, - array('', $terms) - )) - ); - $this->assertEquals( - 1, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, - array(false, 'PSR-2') - )) - ); - $this->assertEquals( - 1, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, - array($tags, '') - )) - ); - $this->assertEquals( - ReferenceLinkDB::$NB_LINKS_TOTAL, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, - '' - )) - ); - } - - /** - * Filter links by #hashtag. - */ - public function testFilterByHashtag() - { - $hashtag = 'hashtag'; - $this->assertEquals( - 3, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG, - $hashtag - )) - ); - - $hashtag = 'private'; - $this->assertEquals( - 1, - count(self::$linkFilter->filter( - LinkFilter::$FILTER_TAG, - $hashtag, - false, - 'private' - )) - ); - } -} diff --git a/tests/bookmark/LinkDBTest.php b/tests/bookmark/LinkDBTest.php index f18a3155..65409e95 100644 --- a/tests/bookmark/LinkDBTest.php +++ b/tests/bookmark/LinkDBTest.php @@ -6,7 +6,7 @@ namespace Shaarli\Bookmark; use DateTime; -use LinkNotFoundException; +use Shaarli\Bookmark\Exception\LinkNotFoundException; use ReferenceLinkDB; use ReflectionClass; use Shaarli; @@ -457,7 +457,7 @@ class LinkDBTest extends \PHPUnit\Framework\TestCase /** * Test filterHash() with an invalid smallhash. * - * @expectedException LinkNotFoundException + * @expectedException \Shaarli\Bookmark\Exception\LinkNotFoundException */ public function testFilterHashInValid1() { @@ -468,7 +468,7 @@ class LinkDBTest extends \PHPUnit\Framework\TestCase /** * Test filterHash() with an empty smallhash. * - * @expectedException LinkNotFoundException + * @expectedException \Shaarli\Bookmark\Exception\LinkNotFoundException */ public function testFilterHashInValid() { diff --git a/tests/bookmark/LinkFilterTest.php b/tests/bookmark/LinkFilterTest.php new file mode 100644 index 00000000..808f8122 --- /dev/null +++ b/tests/bookmark/LinkFilterTest.php @@ -0,0 +1,507 @@ +write(self::$testDatastore); + self::$linkDB = new LinkDB(self::$testDatastore, true, false); + self::$linkFilter = new LinkFilter(self::$linkDB); + } + + /** + * Blank filter. + */ + public function testFilter() + { + $this->assertEquals( + self::$refDB->countLinks(), + count(self::$linkFilter->filter('', '')) + ); + + $this->assertEquals( + self::$refDB->countLinks(), + count(self::$linkFilter->filter('', '', 'all')) + ); + + $this->assertEquals( + self::$refDB->countLinks(), + count(self::$linkFilter->filter('', '', 'randomstr')) + ); + + // Private only. + $this->assertEquals( + self::$refDB->countPrivateLinks(), + count(self::$linkFilter->filter('', '', false, 'private')) + ); + + // Public only. + $this->assertEquals( + self::$refDB->countPublicLinks(), + count(self::$linkFilter->filter('', '', false, 'public')) + ); + + $this->assertEquals( + ReferenceLinkDB::$NB_LINKS_TOTAL, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '')) + ); + + $this->assertEquals( + self::$refDB->countUntaggedLinks(), + count( + self::$linkFilter->filter( + LinkFilter::$FILTER_TAG, + /*$request=*/ + '', + /*$casesensitive=*/ + false, + /*$visibility=*/ + 'all', + /*$untaggedonly=*/ + true + ) + ) + ); + + $this->assertEquals( + ReferenceLinkDB::$NB_LINKS_TOTAL, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '')) + ); + } + + /** + * Filter links using a tag + */ + public function testFilterOneTag() + { + $this->assertEquals( + 4, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false)) + ); + + $this->assertEquals( + 4, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'all')) + ); + + $this->assertEquals( + 4, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'default-blabla')) + ); + + // Private only. + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'private')) + ); + + // Public only. + $this->assertEquals( + 3, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, 'public')) + ); + } + + /** + * Filter links using a tag - case-sensitive + */ + public function testFilterCaseSensitiveTag() + { + $this->assertEquals( + 0, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'mercurial', true)) + ); + + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'Mercurial', true)) + ); + } + + /** + * Filter links using a tag combination + */ + public function testFilterMultipleTags() + { + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'dev cartoon', false)) + ); + } + + /** + * Filter links using a non-existent tag + */ + public function testFilterUnknownTag() + { + $this->assertEquals( + 0, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'null', false)) + ); + } + + /** + * Return links for a given day + */ + public function testFilterDay() + { + $this->assertEquals( + 4, + count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20121206')) + ); + } + + /** + * 404 - day not found + */ + public function testFilterUnknownDay() + { + $this->assertEquals( + 0, + count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '19700101')) + ); + } + + /** + * Use an invalid date format + * @expectedException Exception + * @expectedExceptionMessageRegExp /Invalid date format/ + */ + public function testFilterInvalidDayWithChars() + { + self::$linkFilter->filter(LinkFilter::$FILTER_DAY, 'Rainy day, dream away'); + } + + /** + * Use an invalid date format + * @expectedException Exception + * @expectedExceptionMessageRegExp /Invalid date format/ + */ + public function testFilterInvalidDayDigits() + { + self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20'); + } + + /** + * Retrieve a link entry with its hash + */ + public function testFilterSmallHash() + { + $links = self::$linkFilter->filter(LinkFilter::$FILTER_HASH, 'IuWvgA'); + + $this->assertEquals( + 1, + count($links) + ); + + $this->assertEquals( + 'MediaGoblin', + $links[7]['title'] + ); + } + + /** + * No link for this hash + * + * @expectedException \Shaarli\Bookmark\Exception\LinkNotFoundException + */ + public function testFilterUnknownSmallHash() + { + self::$linkFilter->filter(LinkFilter::$FILTER_HASH, 'Iblaah'); + } + + /** + * Full-text search - no result found. + */ + public function testFilterFullTextNoResult() + { + $this->assertEquals( + 0, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'azertyuiop')) + ); + } + + /** + * Full-text search - result from a link's URL + */ + public function testFilterFullTextURL() + { + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'ars.userfriendly.org')) + ); + + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'ars org')) + ); + } + + /** + * Full-text search - result from a link's title only + */ + public function testFilterFullTextTitle() + { + // use miscellaneous cases + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'userfriendly -')) + ); + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'UserFriendly -')) + ); + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'uSeRFrIendlY -')) + ); + + // use miscellaneous case and offset + $this->assertEquals( + 2, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'RFrIendL')) + ); + } + + /** + * Full-text search - result from the link's description only + */ + public function testFilterFullTextDescription() + { + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'publishing media')) + ); + + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'mercurial w3c')) + ); + + $this->assertEquals( + 3, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '"free software"')) + ); + } + + /** + * Full-text search - result from the link's tags only + */ + public function testFilterFullTextTags() + { + $this->assertEquals( + 6, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web')) + ); + + $this->assertEquals( + 6, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', 'all')) + ); + + $this->assertEquals( + 6, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', 'bla')) + ); + + // Private only. + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', false, 'private')) + ); + + // Public only. + $this->assertEquals( + 5, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', false, 'public')) + ); + } + + /** + * Full-text search - result set from mixed sources + */ + public function testFilterFullTextMixed() + { + $this->assertEquals( + 3, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'free software')) + ); + } + + /** + * Full-text search - test exclusion with '-'. + */ + public function testExcludeSearch() + { + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'free -gnu')) + ); + + $this->assertEquals( + ReferenceLinkDB::$NB_LINKS_TOTAL - 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '-revolution')) + ); + } + + /** + * Full-text search - test AND, exact terms and exclusion combined, across fields. + */ + public function testMultiSearch() + { + $this->assertEquals( + 2, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TEXT, + '"Free Software " stallman "read this" @website stuff' + )) + ); + + $this->assertEquals( + 1, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TEXT, + '"free software " stallman "read this" -beard @website stuff' + )) + ); + } + + /** + * Full-text search - make sure that exact search won't work across fields. + */ + public function testSearchExactTermMultiFieldsKo() + { + $this->assertEquals( + 0, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TEXT, + '"designer naming"' + )) + ); + + $this->assertEquals( + 0, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TEXT, + '"designernaming"' + )) + ); + } + + /** + * Tag search with exclusion. + */ + public function testTagFilterWithExclusion() + { + $this->assertEquals( + 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'gnu -free')) + ); + + $this->assertEquals( + ReferenceLinkDB::$NB_LINKS_TOTAL - 1, + count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '-free')) + ); + } + + /** + * Test crossed search (terms + tags). + */ + public function testFilterCrossedSearch() + { + $terms = '"Free Software " stallman "read this" @website stuff'; + $tags = 'free'; + $this->assertEquals( + 1, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, + array($tags, $terms) + )) + ); + $this->assertEquals( + 2, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, + array('', $terms) + )) + ); + $this->assertEquals( + 1, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, + array(false, 'PSR-2') + )) + ); + $this->assertEquals( + 1, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, + array($tags, '') + )) + ); + $this->assertEquals( + ReferenceLinkDB::$NB_LINKS_TOTAL, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT, + '' + )) + ); + } + + /** + * Filter links by #hashtag. + */ + public function testFilterByHashtag() + { + $hashtag = 'hashtag'; + $this->assertEquals( + 3, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG, + $hashtag + )) + ); + + $hashtag = 'private'; + $this->assertEquals( + 1, + count(self::$linkFilter->filter( + LinkFilter::$FILTER_TAG, + $hashtag, + false, + 'private' + )) + ); + } +} -- cgit v1.2.3 From fe3713d2e5c91e2d07af72b39f321521d3dd470c Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Mon, 3 Dec 2018 01:35:14 +0100 Subject: namespacing: move LinkUtils along \Shaarli\Bookmark classes Signed-off-by: VirtualTam --- application/LinkUtils.php | 222 ------------------ application/bookmark/LinkUtils.php | 222 ++++++++++++++++++ index.php | 2 +- tests/LinkUtilsTest.php | 421 ----------------------------------- tests/bookmark/LinkUtilsTest.php | 333 +++++++++++++++++++++++++++ tests/plugins/PluginMarkdownTest.php | 1 + tests/utils/CurlUtils.php | 94 ++++++++ 7 files changed, 651 insertions(+), 644 deletions(-) delete mode 100644 application/LinkUtils.php create mode 100644 application/bookmark/LinkUtils.php delete mode 100644 tests/LinkUtilsTest.php create mode 100644 tests/bookmark/LinkUtilsTest.php create mode 100644 tests/utils/CurlUtils.php diff --git a/application/LinkUtils.php b/application/LinkUtils.php deleted file mode 100644 index b5110edc..00000000 --- a/application/LinkUtils.php +++ /dev/null @@ -1,222 +0,0 @@ -(.*?)!is', $html, $matches)) { - return trim(str_replace("\n", '', $matches[1])); - } - return false; -} - -/** - * Extract charset from HTTP header if it's defined. - * - * @param string $header HTTP header Content-Type line. - * - * @return bool|string Charset string if found (lowercase), false otherwise. - */ -function header_extract_charset($header) -{ - preg_match('/charset="?([^; ]+)/i', $header, $match); - if (! empty($match[1])) { - return strtolower(trim($match[1])); - } - - return false; -} - -/** - * Extract charset HTML content (tag ). - * - * @param string $html HTML content where to look for charset. - * - * @return bool|string Charset string if found, false otherwise. - */ -function html_extract_charset($html) -{ - // Get encoding specified in HTML header. - preg_match('#/]+)["\']? */?>#Usi', $html, $enc); - if (!empty($enc[1])) { - return strtolower($enc[1]); - } - - return false; -} - -/** - * Count private links in given linklist. - * - * @param array|Countable $links Linklist. - * - * @return int Number of private links. - */ -function count_private($links) -{ - $cpt = 0; - foreach ($links as $link) { - if ($link['private']) { - $cpt += 1; - } - } - - return $cpt; -} - -/** - * In a string, converts URLs to clickable links. - * - * @param string $text input string. - * @param string $redirector if a redirector is set, use it to gerenate links. - * @param bool $urlEncode Use `urlencode()` on the URL after the redirector or not. - * - * @return string returns $text with all links converted to HTML links. - * - * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 - */ -function text2clickable($text, $redirector = '', $urlEncode = true) -{ - $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si'; - - if (empty($redirector)) { - return preg_replace($regex, '$1', $text); - } - // Redirector is set, urlencode the final URL. - return preg_replace_callback( - $regex, - function ($matches) use ($redirector, $urlEncode) { - $url = $urlEncode ? urlencode($matches[1]) : $matches[1]; - return ''. $matches[1] .''; - }, - $text - ); -} - -/** - * Auto-link hashtags. - * - * @param string $description Given description. - * @param string $indexUrl Root URL. - * - * @return string Description with auto-linked hashtags. - */ -function hashtag_autolink($description, $indexUrl = '') -{ - /* - * To support unicode: http://stackoverflow.com/a/35498078/1484919 - * \p{Pc} - to match underscore - * \p{N} - numeric character in any script - * \p{L} - letter from any language - * \p{Mn} - any non marking space (accents, umlauts, etc) - */ - $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; - $replacement = '$1#$2'; - return preg_replace($regex, $replacement, $description); -} - -/** - * This function inserts   where relevant so that multiple spaces are properly displayed in HTML - * even in the absence of
  (This is used in description to keep text formatting).
- *
- * @param string $text input text.
- *
- * @return string formatted text.
- */
-function space2nbsp($text)
-{
-    return preg_replace('/(^| ) /m', '$1 ', $text);
-}
-
-/**
- * Format Shaarli's description
- *
- * @param string $description shaare's description.
- * @param string $redirector  if a redirector is set, use it to gerenate links.
- * @param bool   $urlEncode  Use `urlencode()` on the URL after the redirector or not.
- * @param string $indexUrl    URL to Shaarli's index.
-
- * @return string formatted description.
- */
-function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '')
-{
-    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
-}
-
-/**
- * Generate a small hash for a link.
- *
- * @param DateTime $date Link creation date.
- * @param int      $id   Link ID.
- *
- * @return string the small hash generated from link data.
- */
-function link_small_hash($date, $id)
-{
-    return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
-}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
new file mode 100644
index 00000000..de5b61cb
--- /dev/null
+++ b/application/bookmark/LinkUtils.php
@@ -0,0 +1,222 @@
+(.*?)!is', $html, $matches)) {
+        return trim(str_replace("\n", '', $matches[1]));
+    }
+    return false;
+}
+
+/**
+ * Extract charset from HTTP header if it's defined.
+ *
+ * @param string $header HTTP header Content-Type line.
+ *
+ * @return bool|string Charset string if found (lowercase), false otherwise.
+ */
+function header_extract_charset($header)
+{
+    preg_match('/charset="?([^; ]+)/i', $header, $match);
+    if (! empty($match[1])) {
+        return strtolower(trim($match[1]));
+    }
+
+    return false;
+}
+
+/**
+ * Extract charset HTML content (tag ).
+ *
+ * @param string $html HTML content where to look for charset.
+ *
+ * @return bool|string Charset string if found, false otherwise.
+ */
+function html_extract_charset($html)
+{
+    // Get encoding specified in HTML header.
+    preg_match('#/]+)["\']? */?>#Usi', $html, $enc);
+    if (!empty($enc[1])) {
+        return strtolower($enc[1]);
+    }
+
+    return false;
+}
+
+/**
+ * Count private links in given linklist.
+ *
+ * @param array|Countable $links Linklist.
+ *
+ * @return int Number of private links.
+ */
+function count_private($links)
+{
+    $cpt = 0;
+    foreach ($links as $link) {
+        if ($link['private']) {
+            $cpt += 1;
+        }
+    }
+
+    return $cpt;
+}
+
+/**
+ * In a string, converts URLs to clickable links.
+ *
+ * @param string $text       input string.
+ * @param string $redirector if a redirector is set, use it to gerenate links.
+ * @param bool   $urlEncode  Use `urlencode()` on the URL after the redirector or not.
+ *
+ * @return string returns $text with all links converted to HTML links.
+ *
+ * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
+ */
+function text2clickable($text, $redirector = '', $urlEncode = true)
+{
+    $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
+
+    if (empty($redirector)) {
+        return preg_replace($regex, '$1', $text);
+    }
+    // Redirector is set, urlencode the final URL.
+    return preg_replace_callback(
+        $regex,
+        function ($matches) use ($redirector, $urlEncode) {
+            $url = $urlEncode ? urlencode($matches[1]) : $matches[1];
+            return ''. $matches[1] .'';
+        },
+        $text
+    );
+}
+
+/**
+ * Auto-link hashtags.
+ *
+ * @param string $description Given description.
+ * @param string $indexUrl    Root URL.
+ *
+ * @return string Description with auto-linked hashtags.
+ */
+function hashtag_autolink($description, $indexUrl = '')
+{
+    /*
+     * To support unicode: http://stackoverflow.com/a/35498078/1484919
+     * \p{Pc} - to match underscore
+     * \p{N} - numeric character in any script
+     * \p{L} - letter from any language
+     * \p{Mn} - any non marking space (accents, umlauts, etc)
+     */
+    $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
+    $replacement = '$1#$2';
+    return preg_replace($regex, $replacement, $description);
+}
+
+/**
+ * This function inserts   where relevant so that multiple spaces are properly displayed in HTML
+ * even in the absence of 
  (This is used in description to keep text formatting).
+ *
+ * @param string $text input text.
+ *
+ * @return string formatted text.
+ */
+function space2nbsp($text)
+{
+    return preg_replace('/(^| ) /m', '$1 ', $text);
+}
+
+/**
+ * Format Shaarli's description
+ *
+ * @param string $description shaare's description.
+ * @param string $redirector  if a redirector is set, use it to gerenate links.
+ * @param bool   $urlEncode   Use `urlencode()` on the URL after the redirector or not.
+ * @param string $indexUrl    URL to Shaarli's index.
+
+ * @return string formatted description.
+ */
+function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '')
+{
+    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
+}
+
+/**
+ * Generate a small hash for a link.
+ *
+ * @param DateTime $date Link creation date.
+ * @param int      $id   Link ID.
+ *
+ * @return string the small hash generated from link data.
+ */
+function link_small_hash($date, $id)
+{
+    return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
+}
diff --git a/index.php b/index.php
index dbb3c6fc..146b4457 100644
--- a/index.php
+++ b/index.php
@@ -57,13 +57,13 @@ require_once __DIR__ . '/vendor/autoload.php';
 
 // Shaarli library
 require_once 'application/ApplicationUtils.php';
+require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/config/ConfigPlugin.php';
 require_once 'application/feed/Cache.php';
 require_once 'application/http/HttpUtils.php';
 require_once 'application/http/UrlUtils.php';
 require_once 'application/FileUtils.php';
 require_once 'application/History.php';
-require_once 'application/LinkUtils.php';
 require_once 'application/NetscapeBookmarkUtils.php';
 require_once 'application/TimeZone.php';
 require_once 'application/Utils.php';
diff --git a/tests/LinkUtilsTest.php b/tests/LinkUtilsTest.php
deleted file mode 100644
index 5407159a..00000000
--- a/tests/LinkUtilsTest.php
+++ /dev/null
@@ -1,421 +0,0 @@
-stuff'. $title .'';
-        $this->assertEquals($title, html_extract_title($html));
-        $html = ''. $title .'blablaanother';
-        $this->assertEquals($title, html_extract_title($html));
-    }
-
-    /**
-     * Test html_extract_title() when the title is not found.
-     */
-    public function testHtmlExtractNonExistentTitle()
-    {
-        $html = 'stuff';
-        $this->assertFalse(html_extract_title($html));
-    }
-
-    /**
-     * Test headers_extract_charset() when the charset is found.
-     */
-    public function testHeadersExtractExistentCharset()
-    {
-        $charset = 'x-MacCroatian';
-        $headers = 'text/html; charset='. $charset;
-        $this->assertEquals(strtolower($charset), header_extract_charset($headers));
-    }
-
-    /**
-     * Test headers_extract_charset() when the charset is not found.
-     */
-    public function testHeadersExtractNonExistentCharset()
-    {
-        $headers = '';
-        $this->assertFalse(header_extract_charset($headers));
-
-        $headers = 'text/html';
-        $this->assertFalse(header_extract_charset($headers));
-    }
-
-    /**
-     * Test html_extract_charset() when the charset is found.
-     */
-    public function testHtmlExtractExistentCharset()
-    {
-        $charset = 'x-MacCroatian';
-        $html = 'stuff2';
-        $this->assertEquals(strtolower($charset), html_extract_charset($html));
-    }
-
-    /**
-     * Test html_extract_charset() when the charset is not found.
-     */
-    public function testHtmlExtractNonExistentCharset()
-    {
-        $html = 'stuff';
-        $this->assertFalse(html_extract_charset($html));
-        $html = 'stuff';
-        $this->assertFalse(html_extract_charset($html));
-    }
-
-    /**
-     * Test the download callback with valid value
-     */
-    public function testCurlDownloadCallbackOk()
-    {
-        $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok');
-        $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
-            'end' => 'th=device-width">'
-            .'Refactoring · GitHub'
-            .''
-            .'Refactoring · GitHub'
-            .'',
-            'end' => 'th=device-width">'
-            .'Refactoring · GitHub'
-            .'Refactoring · GitHub'
-            .'http://hello.there/is=someone#here otherstuff';
-        $processedText = text2clickable($text, '');
-        $this->assertEquals($expectedText, $processedText);
-
-        $text = 'stuff http://hello.there/is=someone#here(please) otherstuff';
-        $expectedText = 'stuff '
-            .'http://hello.there/is=someone#here(please) otherstuff';
-        $processedText = text2clickable($text, '');
-        $this->assertEquals($expectedText, $processedText);
-
-        $text = 'stuff http://hello.there/is=someone#here(please)&no otherstuff';
-        $expectedText = 'stuff '
-            .'http://hello.there/is=someone#here(please)&no otherstuff';
-        $processedText = text2clickable($text, '');
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test text2clickable with a redirector set.
-     */
-    public function testText2clickableWithRedirector()
-    {
-        $text = 'stuff http://hello.there/is=someone#here otherstuff';
-        $redirector = 'http://redirector.to';
-        $expectedText = 'stuff http://hello.there/is=someone#here otherstuff';
-        $processedText = text2clickable($text, $redirector);
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test text2clickable a redirector set and without URL encode.
-     */
-    public function testText2clickableWithRedirectorDontEncode()
-    {
-        $text = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
-        $redirector = 'http://redirector.to';
-        $expectedText = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
-        $processedText = text2clickable($text, $redirector, false);
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test testSpace2nbsp.
-     */
-    public function testSpace2nbsp()
-    {
-        $text = '  Are you   thrilled  by flags   ?'. PHP_EOL .' Really?';
-        $expectedText = '  Are you   thrilled  by flags   ?'. PHP_EOL .' Really?';
-        $processedText = space2nbsp($text);
-        $this->assertEquals($expectedText, $processedText);
-    }
-
-    /**
-     * Test hashtags auto-link.
-     */
-    public function testHashtagAutolink()
-    {
-        $index = 'http://domain.tld/';
-        $rawDescription = '#hashtag\n
-            # nothashtag\n
-            test#nothashtag #hashtag \#nothashtag\n
-            test #hashtag #hashtag test #hashtag.test\n
-            #hashtag #hashtag-nothashtag #hashtag_hashtag\n
-            What is #ашок anyway?\n
-            カタカナ #カタカナ」カタカナ\n';
-        $autolinkedDescription = hashtag_autolink($rawDescription, $index);
-
-        $this->assertContains($this->getHashtagLink('hashtag', $index), $autolinkedDescription);
-        $this->assertNotContains(' #hashtag', $autolinkedDescription);
-        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('ашок', $index), $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('カタカナ', $index), $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('hashtag_hashtag', $index), $autolinkedDescription);
-        $this->assertNotContains($this->getHashtagLink('hashtag-nothashtag', $index), $autolinkedDescription);
-    }
-
-    /**
-     * Test hashtags auto-link without index URL.
-     */
-    public function testHashtagAutolinkNoIndex()
-    {
-        $rawDescription = 'blabla #hashtag x#nothashtag';
-        $autolinkedDescription = hashtag_autolink($rawDescription);
-
-        $this->assertContains($this->getHashtagLink('hashtag'), $autolinkedDescription);
-        $this->assertNotContains(' #hashtag', $autolinkedDescription);
-        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
-    }
-
-    /**
-     * Util function to build an hashtag link.
-     *
-     * @param string $hashtag Hashtag name.
-     * @param string $index   Index URL.
-     *
-     * @return string HTML hashtag link.
-     */
-    private function getHashtagLink($hashtag, $index = '')
-    {
-        $hashtagLink = '#$1';
-        return str_replace('$1', $hashtag, $hashtagLink);
-    }
-}
-
-// old style mock: PHPUnit doesn't allow function mock
-
-/**
- * Returns code 200 or html content type.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 200 or 'text/html'
- */
-function ut_curl_getinfo_ok($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 200;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/html; charset=utf-8';
-    }
-}
-
-/**
- * Returns code 200 or html content type without charset.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 200 or 'text/html'
- */
-function ut_curl_getinfo_no_charset($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 200;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/html';
-    }
-}
-
-/**
- * Invalid response code.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 404 or 'text/html'
- */
-function ut_curl_getinfo_rc_ko($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 404;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/html; charset=utf-8';
-    }
-}
-
-/**
- * Invalid content type.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 200 or 'text/plain'
- */
-function ut_curl_getinfo_ct_ko($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 200;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/plain';
-    }
-}
-
-/**
- * Invalid response code and content type.
- *
- * @param resource $ch   cURL resource
- * @param int      $type cURL info type
- *
- * @return int|string 404 or 'text/plain'
- */
-function ut_curl_getinfo_rs_ct_ko($ch, $type)
-{
-    switch ($type) {
-        case CURLINFO_RESPONSE_CODE:
-            return 404;
-        case CURLINFO_CONTENT_TYPE:
-            return 'text/plain';
-    }
-}
diff --git a/tests/bookmark/LinkUtilsTest.php b/tests/bookmark/LinkUtilsTest.php
new file mode 100644
index 00000000..1b8688e6
--- /dev/null
+++ b/tests/bookmark/LinkUtilsTest.php
@@ -0,0 +1,333 @@
+stuff' . $title . '';
+        $this->assertEquals($title, html_extract_title($html));
+        $html = '' . $title . 'blablaanother';
+        $this->assertEquals($title, html_extract_title($html));
+    }
+
+    /**
+     * Test html_extract_title() when the title is not found.
+     */
+    public function testHtmlExtractNonExistentTitle()
+    {
+        $html = 'stuff';
+        $this->assertFalse(html_extract_title($html));
+    }
+
+    /**
+     * Test headers_extract_charset() when the charset is found.
+     */
+    public function testHeadersExtractExistentCharset()
+    {
+        $charset = 'x-MacCroatian';
+        $headers = 'text/html; charset=' . $charset;
+        $this->assertEquals(strtolower($charset), header_extract_charset($headers));
+    }
+
+    /**
+     * Test headers_extract_charset() when the charset is not found.
+     */
+    public function testHeadersExtractNonExistentCharset()
+    {
+        $headers = '';
+        $this->assertFalse(header_extract_charset($headers));
+
+        $headers = 'text/html';
+        $this->assertFalse(header_extract_charset($headers));
+    }
+
+    /**
+     * Test html_extract_charset() when the charset is found.
+     */
+    public function testHtmlExtractExistentCharset()
+    {
+        $charset = 'x-MacCroatian';
+        $html = 'stuff2';
+        $this->assertEquals(strtolower($charset), html_extract_charset($html));
+    }
+
+    /**
+     * Test html_extract_charset() when the charset is not found.
+     */
+    public function testHtmlExtractNonExistentCharset()
+    {
+        $html = 'stuff';
+        $this->assertFalse(html_extract_charset($html));
+        $html = 'stuff';
+        $this->assertFalse(html_extract_charset($html));
+    }
+
+    /**
+     * Test the download callback with valid value
+     */
+    public function testCurlDownloadCallbackOk()
+    {
+        $callback = get_curl_download_callback($charset, $title, 'ut_curl_getinfo_ok');
+        $data = [
+            'HTTP/1.1 200 OK',
+            'Server: GitHub.com',
+            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
+            'Content-Type: text/html; charset=utf-8',
+            'Status: 200 OK',
+            'end' => 'th=device-width">'
+                . 'Refactoring · GitHub'
+                . ''
+                . 'Refactoring · GitHub'
+                . '',
+            'end' => 'th=device-width">'
+                . 'Refactoring · GitHub'
+                . 'Refactoring · GitHub'
+            . 'http://hello.there/is=someone#here otherstuff';
+        $processedText = text2clickable($text, '');
+        $this->assertEquals($expectedText, $processedText);
+
+        $text = 'stuff http://hello.there/is=someone#here(please) otherstuff';
+        $expectedText = 'stuff '
+            . 'http://hello.there/is=someone#here(please) otherstuff';
+        $processedText = text2clickable($text, '');
+        $this->assertEquals($expectedText, $processedText);
+
+        $text = 'stuff http://hello.there/is=someone#here(please)&no otherstuff';
+        $expectedText = 'stuff '
+            . 'http://hello.there/is=someone#here(please)&no otherstuff';
+        $processedText = text2clickable($text, '');
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test text2clickable with a redirector set.
+     */
+    public function testText2clickableWithRedirector()
+    {
+        $text = 'stuff http://hello.there/is=someone#here otherstuff';
+        $redirector = 'http://redirector.to';
+        $expectedText = 'stuff http://hello.there/is=someone#here otherstuff';
+        $processedText = text2clickable($text, $redirector);
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test text2clickable a redirector set and without URL encode.
+     */
+    public function testText2clickableWithRedirectorDontEncode()
+    {
+        $text = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
+        $redirector = 'http://redirector.to';
+        $expectedText = 'stuff http://hello.there/?is=someone&or=something#here otherstuff';
+        $processedText = text2clickable($text, $redirector, false);
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test testSpace2nbsp.
+     */
+    public function testSpace2nbsp()
+    {
+        $text = '  Are you   thrilled  by flags   ?' . PHP_EOL . ' Really?';
+        $expectedText = '  Are you   thrilled  by flags   ?' . PHP_EOL . ' Really?';
+        $processedText = space2nbsp($text);
+        $this->assertEquals($expectedText, $processedText);
+    }
+
+    /**
+     * Test hashtags auto-link.
+     */
+    public function testHashtagAutolink()
+    {
+        $index = 'http://domain.tld/';
+        $rawDescription = '#hashtag\n
+            # nothashtag\n
+            test#nothashtag #hashtag \#nothashtag\n
+            test #hashtag #hashtag test #hashtag.test\n
+            #hashtag #hashtag-nothashtag #hashtag_hashtag\n
+            What is #ашок anyway?\n
+            カタカナ #カタカナ」カタカナ\n';
+        $autolinkedDescription = hashtag_autolink($rawDescription, $index);
+
+        $this->assertContains($this->getHashtagLink('hashtag', $index), $autolinkedDescription);
+        $this->assertNotContains(' #hashtag', $autolinkedDescription);
+        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
+        $this->assertContains($this->getHashtagLink('ашок', $index), $autolinkedDescription);
+        $this->assertContains($this->getHashtagLink('カタカナ', $index), $autolinkedDescription);
+        $this->assertContains($this->getHashtagLink('hashtag_hashtag', $index), $autolinkedDescription);
+        $this->assertNotContains($this->getHashtagLink('hashtag-nothashtag', $index), $autolinkedDescription);
+    }
+
+    /**
+     * Test hashtags auto-link without index URL.
+     */
+    public function testHashtagAutolinkNoIndex()
+    {
+        $rawDescription = 'blabla #hashtag x#nothashtag';
+        $autolinkedDescription = hashtag_autolink($rawDescription);
+
+        $this->assertContains($this->getHashtagLink('hashtag'), $autolinkedDescription);
+        $this->assertNotContains(' #hashtag', $autolinkedDescription);
+        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
+    }
+
+    /**
+     * Util function to build an hashtag link.
+     *
+     * @param string $hashtag Hashtag name.
+     * @param string $index Index URL.
+     *
+     * @return string HTML hashtag link.
+     */
+    private function getHashtagLink($hashtag, $index = '')
+    {
+        $hashtagLink = '#$1';
+        return str_replace('$1', $hashtag, $hashtagLink);
+    }
+}
diff --git a/tests/plugins/PluginMarkdownTest.php b/tests/plugins/PluginMarkdownTest.php
index 44364b05..d6951866 100644
--- a/tests/plugins/PluginMarkdownTest.php
+++ b/tests/plugins/PluginMarkdownTest.php
@@ -5,6 +5,7 @@ use Shaarli\Config\ConfigManager;
  * PluginMarkdownTest.php
  */
 
+require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/Utils.php';
 require_once 'plugins/markdown/markdown.php';
 
diff --git a/tests/utils/CurlUtils.php b/tests/utils/CurlUtils.php
new file mode 100644
index 00000000..1cc4907e
--- /dev/null
+++ b/tests/utils/CurlUtils.php
@@ -0,0 +1,94 @@
+
Date: Mon, 3 Dec 2018 23:22:49 +0100
Subject: namespacing: add curl-ext to suggested dependencies

Signed-off-by: VirtualTam 
---
 composer.json |  3 +++
 composer.lock | 87 ++++++++++++++++++++++++++++++-----------------------------
 2 files changed, 47 insertions(+), 43 deletions(-)

diff --git a/composer.json b/composer.json
index 7d0f96b3..4c14e794 100644
--- a/composer.json
+++ b/composer.json
@@ -29,6 +29,9 @@
         "phpunit/phpunit": "^5.0",
         "squizlabs/php_codesniffer": "2.*"
     },
+    "suggest": {
+        "ext-curl": "*"
+    },
     "autoload": {
         "psr-4": {
             "Shaarli\\": "application",
diff --git a/composer.lock b/composer.lock
index c43dad6f..5723cff4 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "3876b34296fedb365517b785af8384de",
+    "content-hash": "45937c29ca4a55504b0d0a60a03c42c6",
     "packages": [
         {
             "name": "arthurhoaro/web-thumbnailer",
@@ -195,16 +195,16 @@
         },
         {
             "name": "gettext/languages",
-            "version": "2.4.0",
+            "version": "2.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mlocati/cldr-to-gettext-plural-rules.git",
-                "reference": "1b74377bd0c4cd87e8d72b948f5d8867e23505a5"
+                "reference": "78db2d17933f0765a102f368a6663f057162ddbd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mlocati/cldr-to-gettext-plural-rules/zipball/1b74377bd0c4cd87e8d72b948f5d8867e23505a5",
-                "reference": "1b74377bd0c4cd87e8d72b948f5d8867e23505a5",
+                "url": "https://api.github.com/repos/mlocati/cldr-to-gettext-plural-rules/zipball/78db2d17933f0765a102f368a6663f057162ddbd",
+                "reference": "78db2d17933f0765a102f368a6663f057162ddbd",
                 "shasum": ""
             },
             "require": {
@@ -252,7 +252,7 @@
                 "translations",
                 "unicode"
             ],
-            "time": "2018-06-21T15:58:36+00:00"
+            "time": "2018-11-13T22:06:07+00:00"
         },
         {
             "name": "katzgrau/klogger",
@@ -542,16 +542,16 @@
         },
         {
             "name": "psr/log",
-            "version": "1.0.2",
+            "version": "1.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d"
+                "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/4ebe3a8bf773a19edfe0a84b6585ba3d401b724d",
-                "reference": "4ebe3a8bf773a19edfe0a84b6585ba3d401b724d",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd",
+                "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd",
                 "shasum": ""
             },
             "require": {
@@ -585,7 +585,7 @@
                 "psr",
                 "psr-3"
             ],
-            "time": "2016-10-10T12:19:37+00:00"
+            "time": "2018-11-20T15:27:04+00:00"
         },
         {
             "name": "pubsubhubbub/publisher",
@@ -2023,16 +2023,16 @@
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "2.9.1",
+            "version": "2.9.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "dcbed1074f8244661eecddfc2a675430d8d33f62"
+                "reference": "2acf168de78487db620ab4bc524135a13cfe6745"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/dcbed1074f8244661eecddfc2a675430d8d33f62",
-                "reference": "dcbed1074f8244661eecddfc2a675430d8d33f62",
+                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/2acf168de78487db620ab4bc524135a13cfe6745",
+                "reference": "2acf168de78487db620ab4bc524135a13cfe6745",
                 "shasum": ""
             },
             "require": {
@@ -2097,20 +2097,20 @@
                 "phpcs",
                 "standards"
             ],
-            "time": "2017-05-22T02:43:20+00:00"
+            "time": "2018-11-07T22:31:41+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v3.4.17",
+            "version": "v3.4.19",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "3b2b415d4c48fbefca7dc742aa0a0171bfae4e0b"
+                "reference": "8f80fc39bbc3b7c47ee54ba7aa2653521ace94bb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/3b2b415d4c48fbefca7dc742aa0a0171bfae4e0b",
-                "reference": "3b2b415d4c48fbefca7dc742aa0a0171bfae4e0b",
+                "url": "https://api.github.com/repos/symfony/console/zipball/8f80fc39bbc3b7c47ee54ba7aa2653521ace94bb",
+                "reference": "8f80fc39bbc3b7c47ee54ba7aa2653521ace94bb",
                 "shasum": ""
             },
             "require": {
@@ -2166,20 +2166,20 @@
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2018-10-02T16:33:53+00:00"
+            "time": "2018-11-26T12:48:07+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v3.4.17",
+            "version": "v3.4.19",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "0a612e9dfbd2ccce03eb174365f31ecdca930ff6"
+                "reference": "2016b3eec2e49c127dd02d0ef44a35c53181560d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/0a612e9dfbd2ccce03eb174365f31ecdca930ff6",
-                "reference": "0a612e9dfbd2ccce03eb174365f31ecdca930ff6",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/2016b3eec2e49c127dd02d0ef44a35c53181560d",
+                "reference": "2016b3eec2e49c127dd02d0ef44a35c53181560d",
                 "shasum": ""
             },
             "require": {
@@ -2222,20 +2222,20 @@
             ],
             "description": "Symfony Debug Component",
             "homepage": "https://symfony.com",
-            "time": "2018-10-02T16:33:53+00:00"
+            "time": "2018-11-11T19:48:54+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v3.4.17",
+            "version": "v3.4.19",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "54ba444dddc5bd5708a34bd095ea67c6eb54644d"
+                "reference": "6cf2be5cbd0e87aa35c01f80ae0bf40b6798e442"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/54ba444dddc5bd5708a34bd095ea67c6eb54644d",
-                "reference": "54ba444dddc5bd5708a34bd095ea67c6eb54644d",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/6cf2be5cbd0e87aa35c01f80ae0bf40b6798e442",
+                "reference": "6cf2be5cbd0e87aa35c01f80ae0bf40b6798e442",
                 "shasum": ""
             },
             "require": {
@@ -2271,11 +2271,11 @@
             ],
             "description": "Symfony Finder Component",
             "homepage": "https://symfony.com",
-            "time": "2018-10-03T08:46:40+00:00"
+            "time": "2018-11-11T19:48:54+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.9.0",
+            "version": "v1.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
@@ -2333,16 +2333,16 @@
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.9.0",
+            "version": "v1.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8"
+                "reference": "c79c051f5b3a46be09205c73b80b346e4153e494"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d0cd638f4634c16d8df4508e847f14e9e43168b8",
-                "reference": "d0cd638f4634c16d8df4508e847f14e9e43168b8",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/c79c051f5b3a46be09205c73b80b346e4153e494",
+                "reference": "c79c051f5b3a46be09205c73b80b346e4153e494",
                 "shasum": ""
             },
             "require": {
@@ -2388,20 +2388,20 @@
                 "portable",
                 "shim"
             ],
-            "time": "2018-08-06T14:22:27+00:00"
+            "time": "2018-09-21T13:07:52+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v3.4.17",
+            "version": "v3.4.19",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "640b6c27fed4066d64b64d5903a86043f4a4de7f"
+                "reference": "291e13d808bec481eab83f301f7bff3e699ef603"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/640b6c27fed4066d64b64d5903a86043f4a4de7f",
-                "reference": "640b6c27fed4066d64b64d5903a86043f4a4de7f",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/291e13d808bec481eab83f301f7bff3e699ef603",
+                "reference": "291e13d808bec481eab83f301f7bff3e699ef603",
                 "shasum": ""
             },
             "require": {
@@ -2447,7 +2447,7 @@
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2018-10-02T16:33:53+00:00"
+            "time": "2018-11-11T19:48:54+00:00"
         },
         {
             "name": "theseer/fdomdocument",
@@ -2548,7 +2548,8 @@
     "prefer-stable": false,
     "prefer-lowest": false,
     "platform": {
-        "php": ">=5.6"
+        "php": ">=5.6",
+        "ext-zlib": "*"
     },
     "platform-dev": [],
     "platform-overrides": {
-- 
cgit v1.2.3


From bcf056c9d92e5240e645c76a4cdc8ae159693f9a Mon Sep 17 00:00:00 2001
From: VirtualTam 
Date: Mon, 3 Dec 2018 23:49:20 +0100
Subject: namespacing: \Shaarli\Updater

Signed-off-by: VirtualTam 
---
 application/Updater.php                            | 637 ----------------
 application/config/ConfigPhp.php                   |   2 +-
 application/updater/Updater.php                    | 553 ++++++++++++++
 application/updater/UpdaterUtils.php               |  39 +
 application/updater/exception/UpdaterException.php |  60 ++
 composer.json                                      |   4 +-
 index.php                                          |   3 +-
 tests/Updater/DummyUpdater.php                     |  70 --
 tests/Updater/UpdaterTest.php                      | 810 --------------------
 tests/updater/DummyUpdater.php                     |  73 ++
 tests/updater/UpdaterTest.php                      | 815 +++++++++++++++++++++
 tests/utils/config/configPhp.php                   |   2 +-
 12 files changed, 1547 insertions(+), 1521 deletions(-)
 delete mode 100644 application/Updater.php
 create mode 100644 application/updater/Updater.php
 create mode 100644 application/updater/UpdaterUtils.php
 create mode 100644 application/updater/exception/UpdaterException.php
 delete mode 100644 tests/Updater/DummyUpdater.php
 delete mode 100644 tests/Updater/UpdaterTest.php
 create mode 100644 tests/updater/DummyUpdater.php
 create mode 100644 tests/updater/UpdaterTest.php

diff --git a/application/Updater.php b/application/Updater.php
deleted file mode 100644
index ca05ecc2..00000000
--- a/application/Updater.php
+++ /dev/null
@@ -1,637 +0,0 @@
-doneUpdates = $doneUpdates;
-        $this->linkDB = $linkDB;
-        $this->conf = $conf;
-        $this->isLoggedIn = $isLoggedIn;
-        $this->session = &$session;
-
-        // Retrieve all update methods.
-        $class = new ReflectionClass($this);
-        $this->methods = $class->getMethods();
-    }
-
-    /**
-     * Run all new updates.
-     * Update methods have to start with 'updateMethod' and return true (on success).
-     *
-     * @return array An array containing ran updates.
-     *
-     * @throws UpdaterException If something went wrong.
-     */
-    public function update()
-    {
-        $updatesRan = array();
-
-        // If the user isn't logged in, exit without updating.
-        if ($this->isLoggedIn !== true) {
-            return $updatesRan;
-        }
-
-        if ($this->methods === null) {
-            throw new UpdaterException(t('Couldn\'t retrieve Updater class methods.'));
-        }
-
-        foreach ($this->methods as $method) {
-            // Not an update method or already done, pass.
-            if (! startsWith($method->getName(), 'updateMethod')
-                || in_array($method->getName(), $this->doneUpdates)
-            ) {
-                continue;
-            }
-
-            try {
-                $method->setAccessible(true);
-                $res = $method->invoke($this);
-                // Update method must return true to be considered processed.
-                if ($res === true) {
-                    $updatesRan[] = $method->getName();
-                }
-            } catch (Exception $e) {
-                throw new UpdaterException($method, $e);
-            }
-        }
-
-        $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
-
-        return $updatesRan;
-    }
-
-    /**
-     * @return array Updates methods already processed.
-     */
-    public function getDoneUpdates()
-    {
-        return $this->doneUpdates;
-    }
-
-    /**
-     * Move deprecated options.php to config.php.
-     *
-     * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
-     *    options.php is not supported anymore.
-     */
-    public function updateMethodMergeDeprecatedConfigFile()
-    {
-        if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
-            include $this->conf->get('resource.data_dir') . '/options.php';
-
-            // Load GLOBALS into config
-            $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
-            $allowedKeys[] = 'config';
-            foreach ($GLOBALS as $key => $value) {
-                if (in_array($key, $allowedKeys)) {
-                    $this->conf->set($key, $value);
-                }
-            }
-            $this->conf->write($this->isLoggedIn);
-            unlink($this->conf->get('resource.data_dir').'/options.php');
-        }
-
-        return true;
-    }
-
-    /**
-     * Move old configuration in PHP to the new config system in JSON format.
-     *
-     * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
-     * It will also convert legacy setting keys to the new ones.
-     */
-    public function updateMethodConfigToJson()
-    {
-        // JSON config already exists, nothing to do.
-        if ($this->conf->getConfigIO() instanceof ConfigJson) {
-            return true;
-        }
-
-        $configPhp = new ConfigPhp();
-        $configJson = new ConfigJson();
-        $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
-        rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
-        $this->conf->setConfigIO($configJson);
-        $this->conf->reload();
-
-        $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
-        foreach (ConfigPhp::$ROOT_KEYS as $key) {
-            $this->conf->set($legacyMap[$key], $oldConfig[$key]);
-        }
-
-        // Set sub config keys (config and plugins)
-        $subConfig = array('config', 'plugins');
-        foreach ($subConfig as $sub) {
-            foreach ($oldConfig[$sub] as $key => $value) {
-                if (isset($legacyMap[$sub .'.'. $key])) {
-                    $configKey = $legacyMap[$sub .'.'. $key];
-                } else {
-                    $configKey = $sub .'.'. $key;
-                }
-                $this->conf->set($configKey, $value);
-            }
-        }
-
-        try {
-            $this->conf->write($this->isLoggedIn);
-            return true;
-        } catch (IOException $e) {
-            error_log($e->getMessage());
-            return false;
-        }
-    }
-
-    /**
-     * Escape settings which have been manually escaped in every request in previous versions:
-     *   - general.title
-     *   - general.header_link
-     *   - redirector.url
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodEscapeUnescapedConfig()
-    {
-        try {
-            $this->conf->set('general.title', escape($this->conf->get('general.title')));
-            $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
-            $this->conf->set('redirector.url', escape($this->conf->get('redirector.url')));
-            $this->conf->write($this->isLoggedIn);
-        } catch (Exception $e) {
-            error_log($e->getMessage());
-            return false;
-        }
-        return true;
-    }
-
-    /**
-     * Update the database to use the new ID system, which replaces linkdate primary keys.
-     * Also, creation and update dates are now DateTime objects (done by LinkDB).
-     *
-     * Since this update is very sensitve (changing the whole database), the datastore will be
-     * automatically backed up into the file datastore..php.
-     *
-     * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
-     * which will be saved by this method.
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodDatastoreIds()
-    {
-        // up to date database
-        if (isset($this->linkDB[0])) {
-            return true;
-        }
-
-        $save = $this->conf->get('resource.data_dir') .'/datastore.'. date('YmdHis') .'.php';
-        copy($this->conf->get('resource.datastore'), $save);
-
-        $links = array();
-        foreach ($this->linkDB as $offset => $value) {
-            $links[] = $value;
-            unset($this->linkDB[$offset]);
-        }
-        $links = array_reverse($links);
-        $cpt = 0;
-        foreach ($links as $l) {
-            unset($l['linkdate']);
-            $l['id'] = $cpt;
-            $this->linkDB[$cpt++] = $l;
-        }
-
-        $this->linkDB->save($this->conf->get('resource.page_cache'));
-        $this->linkDB->reorder();
-
-        return true;
-    }
-
-    /**
-     * Rename tags starting with a '-' to work with tag exclusion search.
-     */
-    public function updateMethodRenameDashTags()
-    {
-        $linklist = $this->linkDB->filterSearch();
-        foreach ($linklist as $key => $link) {
-            $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
-            $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
-            $this->linkDB[$key] = $link;
-        }
-        $this->linkDB->save($this->conf->get('resource.page_cache'));
-        return true;
-    }
-
-    /**
-     * Initialize API settings:
-     *   - api.enabled: true
-     *   - api.secret: generated secret
-     */
-    public function updateMethodApiSettings()
-    {
-        if ($this->conf->exists('api.secret')) {
-            return true;
-        }
-
-        $this->conf->set('api.enabled', true);
-        $this->conf->set(
-            'api.secret',
-            generate_api_secret(
-                $this->conf->get('credentials.login'),
-                $this->conf->get('credentials.salt')
-            )
-        );
-        $this->conf->write($this->isLoggedIn);
-        return true;
-    }
-
-    /**
-     * New setting: theme name. If the default theme is used, nothing to do.
-     *
-     * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
-     * and the current theme is set as default in the theme setting.
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodDefaultTheme()
-    {
-        // raintpl_tpl isn't the root template directory anymore.
-        // We run the update only if this folder still contains the template files.
-        $tplDir = $this->conf->get('resource.raintpl_tpl');
-        $tplFile = $tplDir . '/linklist.html';
-        if (! file_exists($tplFile)) {
-            return true;
-        }
-
-        $parent = dirname($tplDir);
-        $this->conf->set('resource.raintpl_tpl', $parent);
-        $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
-        $this->conf->write($this->isLoggedIn);
-
-        // Dependency injection gore
-        RainTPL::$tpl_dir = $tplDir;
-
-        return true;
-    }
-
-    /**
-     * Move the file to inc/user.css to data/user.css.
-     *
-     * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodMoveUserCss()
-    {
-        if (! is_file('inc/user.css')) {
-            return true;
-        }
-
-        return rename('inc/user.css', 'data/user.css');
-    }
-
-    /**
-     * * `markdown_escape` is a new setting, set to true as default.
-     *
-     * If the markdown plugin was already enabled, escaping is disabled to avoid
-     * breaking existing entries.
-     */
-    public function updateMethodEscapeMarkdown()
-    {
-        if ($this->conf->exists('security.markdown_escape')) {
-            return true;
-        }
-
-        if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
-            $this->conf->set('security.markdown_escape', false);
-        } else {
-            $this->conf->set('security.markdown_escape', true);
-        }
-        $this->conf->write($this->isLoggedIn);
-
-        return true;
-    }
-
-    /**
-     * Add 'http://' to Piwik URL the setting is set.
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodPiwikUrl()
-    {
-        if (! $this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
-            return true;
-        }
-
-        $this->conf->set('plugins.PIWIK_URL', 'http://'. $this->conf->get('plugins.PIWIK_URL'));
-        $this->conf->write($this->isLoggedIn);
-
-        return true;
-    }
-
-    /**
-     * Use ATOM feed as default.
-     */
-    public function updateMethodAtomDefault()
-    {
-        if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
-            return true;
-        }
-
-        $this->conf->set('feed.show_atom', true);
-        $this->conf->write($this->isLoggedIn);
-
-        return true;
-    }
-
-    /**
-     * Update updates.check_updates_branch setting.
-     *
-     * If the current major version digit matches the latest branch
-     * major version digit, we set the branch to `latest`,
-     * otherwise we'll check updates on the `stable` branch.
-     *
-     * No update required for the dev version.
-     *
-     * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
-     *
-     * FIXME! This needs to be removed when we switch to first digit major version
-     *        instead of the second one since the versionning process will change.
-     */
-    public function updateMethodCheckUpdateRemoteBranch()
-    {
-        if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
-            return true;
-        }
-
-        // Get latest branch major version digit
-        $latestVersion = ApplicationUtils::getLatestGitVersionCode(
-            'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
-            5
-        );
-        if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
-            return false;
-        }
-        $latestMajor = $matches[1];
-
-        // Get current major version digit
-        preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
-        $currentMajor = $matches[1];
-
-        if ($currentMajor === $latestMajor) {
-            $branch = 'latest';
-        } else {
-            $branch = 'stable';
-        }
-        $this->conf->set('updates.check_updates_branch', $branch);
-        $this->conf->write($this->isLoggedIn);
-        return true;
-    }
-
-    /**
-     * Reset history store file due to date format change.
-     */
-    public function updateMethodResetHistoryFile()
-    {
-        if (is_file($this->conf->get('resource.history'))) {
-            unlink($this->conf->get('resource.history'));
-        }
-        return true;
-    }
-
-    /**
-     * Save the datastore -> the link order is now applied when links are saved.
-     */
-    public function updateMethodReorderDatastore()
-    {
-        $this->linkDB->save($this->conf->get('resource.page_cache'));
-        return true;
-    }
-
-    /**
-     * Change privateonly session key to visibility.
-     */
-    public function updateMethodVisibilitySession()
-    {
-        if (isset($_SESSION['privateonly'])) {
-            unset($_SESSION['privateonly']);
-            $_SESSION['visibility'] = 'private';
-        }
-        return true;
-    }
-
-    /**
-     * Add download size and timeout to the configuration file
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodDownloadSizeAndTimeoutConf()
-    {
-        if ($this->conf->exists('general.download_max_size')
-            && $this->conf->exists('general.download_timeout')
-        ) {
-            return true;
-        }
-
-        if (! $this->conf->exists('general.download_max_size')) {
-            $this->conf->set('general.download_max_size', 1024*1024*4);
-        }
-
-        if (! $this->conf->exists('general.download_timeout')) {
-            $this->conf->set('general.download_timeout', 30);
-        }
-
-        $this->conf->write($this->isLoggedIn);
-        return true;
-    }
-
-    /**
-     * * Move thumbnails management to WebThumbnailer, coming with new settings.
-     */
-    public function updateMethodWebThumbnailer()
-    {
-        if ($this->conf->exists('thumbnails.mode')) {
-            return true;
-        }
-
-        $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
-        $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
-        $this->conf->set('thumbnails.width', 125);
-        $this->conf->set('thumbnails.height', 90);
-        $this->conf->remove('thumbnail');
-        $this->conf->write(true);
-
-        if ($thumbnailsEnabled) {
-            $this->session['warnings'][] = t(
-                'You have enabled or changed thumbnails mode. Please synchronize them.'
-            );
-        }
-
-        return true;
-    }
-
-    /**
-     * Set sticky = false on all links
-     *
-     * @return bool true if the update is successful, false otherwise.
-     */
-    public function updateMethodSetSticky()
-    {
-        foreach ($this->linkDB as $key => $link) {
-            if (isset($link['sticky'])) {
-                return true;
-            }
-            $link['sticky'] = false;
-            $this->linkDB[$key] = $link;
-        }
-
-        $this->linkDB->save($this->conf->get('resource.page_cache'));
-
-        return true;
-    }
-}
-
-/**
- * Class UpdaterException.
- */
-class UpdaterException extends Exception
-{
-    /**
-     * @var string Method where the error occurred.
-     */
-    protected $method;
-
-    /**
-     * @var Exception The parent exception.
-     */
-    protected $previous;
-
-    /**
-     * Constructor.
-     *
-     * @param string         $message  Force the error message if set.
-     * @param string         $method   Method where the error occurred.
-     * @param Exception|bool $previous Parent exception.
-     */
-    public function __construct($message = '', $method = '', $previous = false)
-    {
-        $this->method = $method;
-        $this->previous = $previous;
-        $this->message = $this->buildMessage($message);
-    }
-
-    /**
-     * Build the exception error message.
-     *
-     * @param string $message Optional given error message.
-     *
-     * @return string The built error message.
-     */
-    private function buildMessage($message)
-    {
-        $out = '';
-        if (! empty($message)) {
-            $out .= $message . PHP_EOL;
-        }
-
-        if (! empty($this->method)) {
-            $out .= t('An error occurred while running the update ') . $this->method . PHP_EOL;
-        }
-
-        if (! empty($this->previous)) {
-            $out .= '  '. $this->previous->getMessage();
-        }
-
-        return $out;
-    }
-}
-
-/**
- * Read the updates file, and return already done updates.
- *
- * @param string $updatesFilepath Updates file path.
- *
- * @return array Already done update methods.
- */
-function read_updates_file($updatesFilepath)
-{
-    if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
-        $content = file_get_contents($updatesFilepath);
-        if (! empty($content)) {
-            return explode(';', $content);
-        }
-    }
-    return array();
-}
-
-/**
- * Write updates file.
- *
- * @param string $updatesFilepath Updates file path.
- * @param array  $updates         Updates array to write.
- *
- * @throws Exception Couldn't write version number.
- */
-function write_updates_file($updatesFilepath, $updates)
-{
-    if (empty($updatesFilepath)) {
-        throw new Exception(t('Updates file path is not set, can\'t write updates.'));
-    }
-
-    $res = file_put_contents($updatesFilepath, implode(';', $updates));
-    if ($res === false) {
-        throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.'));
-    }
-}
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index 9ed5d31f..cad34594 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -27,7 +27,7 @@ class ConfigPhp implements ConfigIO
     /**
      * Map legacy config keys with the new ones.
      * If ConfigPhp is used, getting  will actually look for .
-     * The Updater will use this array to transform keys when switching to JSON.
+     * The updater will use this array to transform keys when switching to JSON.
      *
      * @var array current key => legacy key.
      */
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
new file mode 100644
index 00000000..55251a30
--- /dev/null
+++ b/application/updater/Updater.php
@@ -0,0 +1,553 @@
+doneUpdates = $doneUpdates;
+        $this->linkDB = $linkDB;
+        $this->conf = $conf;
+        $this->isLoggedIn = $isLoggedIn;
+        $this->session = &$session;
+
+        // Retrieve all update methods.
+        $class = new ReflectionClass($this);
+        $this->methods = $class->getMethods();
+    }
+
+    /**
+     * Run all new updates.
+     * Update methods have to start with 'updateMethod' and return true (on success).
+     *
+     * @return array An array containing ran updates.
+     *
+     * @throws UpdaterException If something went wrong.
+     */
+    public function update()
+    {
+        $updatesRan = array();
+
+        // If the user isn't logged in, exit without updating.
+        if ($this->isLoggedIn !== true) {
+            return $updatesRan;
+        }
+
+        if ($this->methods === null) {
+            throw new UpdaterException(t('Couldn\'t retrieve updater class methods.'));
+        }
+
+        foreach ($this->methods as $method) {
+            // Not an update method or already done, pass.
+            if (!startsWith($method->getName(), 'updateMethod')
+                || in_array($method->getName(), $this->doneUpdates)
+            ) {
+                continue;
+            }
+
+            try {
+                $method->setAccessible(true);
+                $res = $method->invoke($this);
+                // Update method must return true to be considered processed.
+                if ($res === true) {
+                    $updatesRan[] = $method->getName();
+                }
+            } catch (Exception $e) {
+                throw new UpdaterException($method, $e);
+            }
+        }
+
+        $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
+
+        return $updatesRan;
+    }
+
+    /**
+     * @return array Updates methods already processed.
+     */
+    public function getDoneUpdates()
+    {
+        return $this->doneUpdates;
+    }
+
+    /**
+     * Move deprecated options.php to config.php.
+     *
+     * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
+     *    options.php is not supported anymore.
+     */
+    public function updateMethodMergeDeprecatedConfigFile()
+    {
+        if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
+            include $this->conf->get('resource.data_dir') . '/options.php';
+
+            // Load GLOBALS into config
+            $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
+            $allowedKeys[] = 'config';
+            foreach ($GLOBALS as $key => $value) {
+                if (in_array($key, $allowedKeys)) {
+                    $this->conf->set($key, $value);
+                }
+            }
+            $this->conf->write($this->isLoggedIn);
+            unlink($this->conf->get('resource.data_dir') . '/options.php');
+        }
+
+        return true;
+    }
+
+    /**
+     * Move old configuration in PHP to the new config system in JSON format.
+     *
+     * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
+     * It will also convert legacy setting keys to the new ones.
+     */
+    public function updateMethodConfigToJson()
+    {
+        // JSON config already exists, nothing to do.
+        if ($this->conf->getConfigIO() instanceof ConfigJson) {
+            return true;
+        }
+
+        $configPhp = new ConfigPhp();
+        $configJson = new ConfigJson();
+        $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
+        rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
+        $this->conf->setConfigIO($configJson);
+        $this->conf->reload();
+
+        $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
+        foreach (ConfigPhp::$ROOT_KEYS as $key) {
+            $this->conf->set($legacyMap[$key], $oldConfig[$key]);
+        }
+
+        // Set sub config keys (config and plugins)
+        $subConfig = array('config', 'plugins');
+        foreach ($subConfig as $sub) {
+            foreach ($oldConfig[$sub] as $key => $value) {
+                if (isset($legacyMap[$sub . '.' . $key])) {
+                    $configKey = $legacyMap[$sub . '.' . $key];
+                } else {
+                    $configKey = $sub . '.' . $key;
+                }
+                $this->conf->set($configKey, $value);
+            }
+        }
+
+        try {
+            $this->conf->write($this->isLoggedIn);
+            return true;
+        } catch (IOException $e) {
+            error_log($e->getMessage());
+            return false;
+        }
+    }
+
+    /**
+     * Escape settings which have been manually escaped in every request in previous versions:
+     *   - general.title
+     *   - general.header_link
+     *   - redirector.url
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodEscapeUnescapedConfig()
+    {
+        try {
+            $this->conf->set('general.title', escape($this->conf->get('general.title')));
+            $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
+            $this->conf->set('redirector.url', escape($this->conf->get('redirector.url')));
+            $this->conf->write($this->isLoggedIn);
+        } catch (Exception $e) {
+            error_log($e->getMessage());
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Update the database to use the new ID system, which replaces linkdate primary keys.
+     * Also, creation and update dates are now DateTime objects (done by LinkDB).
+     *
+     * Since this update is very sensitve (changing the whole database), the datastore will be
+     * automatically backed up into the file datastore..php.
+     *
+     * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
+     * which will be saved by this method.
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodDatastoreIds()
+    {
+        // up to date database
+        if (isset($this->linkDB[0])) {
+            return true;
+        }
+
+        $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
+        copy($this->conf->get('resource.datastore'), $save);
+
+        $links = array();
+        foreach ($this->linkDB as $offset => $value) {
+            $links[] = $value;
+            unset($this->linkDB[$offset]);
+        }
+        $links = array_reverse($links);
+        $cpt = 0;
+        foreach ($links as $l) {
+            unset($l['linkdate']);
+            $l['id'] = $cpt;
+            $this->linkDB[$cpt++] = $l;
+        }
+
+        $this->linkDB->save($this->conf->get('resource.page_cache'));
+        $this->linkDB->reorder();
+
+        return true;
+    }
+
+    /**
+     * Rename tags starting with a '-' to work with tag exclusion search.
+     */
+    public function updateMethodRenameDashTags()
+    {
+        $linklist = $this->linkDB->filterSearch();
+        foreach ($linklist as $key => $link) {
+            $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
+            $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
+            $this->linkDB[$key] = $link;
+        }
+        $this->linkDB->save($this->conf->get('resource.page_cache'));
+        return true;
+    }
+
+    /**
+     * Initialize API settings:
+     *   - api.enabled: true
+     *   - api.secret: generated secret
+     */
+    public function updateMethodApiSettings()
+    {
+        if ($this->conf->exists('api.secret')) {
+            return true;
+        }
+
+        $this->conf->set('api.enabled', true);
+        $this->conf->set(
+            'api.secret',
+            generate_api_secret(
+                $this->conf->get('credentials.login'),
+                $this->conf->get('credentials.salt')
+            )
+        );
+        $this->conf->write($this->isLoggedIn);
+        return true;
+    }
+
+    /**
+     * New setting: theme name. If the default theme is used, nothing to do.
+     *
+     * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
+     * and the current theme is set as default in the theme setting.
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodDefaultTheme()
+    {
+        // raintpl_tpl isn't the root template directory anymore.
+        // We run the update only if this folder still contains the template files.
+        $tplDir = $this->conf->get('resource.raintpl_tpl');
+        $tplFile = $tplDir . '/linklist.html';
+        if (!file_exists($tplFile)) {
+            return true;
+        }
+
+        $parent = dirname($tplDir);
+        $this->conf->set('resource.raintpl_tpl', $parent);
+        $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
+        $this->conf->write($this->isLoggedIn);
+
+        // Dependency injection gore
+        RainTPL::$tpl_dir = $tplDir;
+
+        return true;
+    }
+
+    /**
+     * Move the file to inc/user.css to data/user.css.
+     *
+     * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodMoveUserCss()
+    {
+        if (!is_file('inc/user.css')) {
+            return true;
+        }
+
+        return rename('inc/user.css', 'data/user.css');
+    }
+
+    /**
+     * * `markdown_escape` is a new setting, set to true as default.
+     *
+     * If the markdown plugin was already enabled, escaping is disabled to avoid
+     * breaking existing entries.
+     */
+    public function updateMethodEscapeMarkdown()
+    {
+        if ($this->conf->exists('security.markdown_escape')) {
+            return true;
+        }
+
+        if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
+            $this->conf->set('security.markdown_escape', false);
+        } else {
+            $this->conf->set('security.markdown_escape', true);
+        }
+        $this->conf->write($this->isLoggedIn);
+
+        return true;
+    }
+
+    /**
+     * Add 'http://' to Piwik URL the setting is set.
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodPiwikUrl()
+    {
+        if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
+            return true;
+        }
+
+        $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL'));
+        $this->conf->write($this->isLoggedIn);
+
+        return true;
+    }
+
+    /**
+     * Use ATOM feed as default.
+     */
+    public function updateMethodAtomDefault()
+    {
+        if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
+            return true;
+        }
+
+        $this->conf->set('feed.show_atom', true);
+        $this->conf->write($this->isLoggedIn);
+
+        return true;
+    }
+
+    /**
+     * Update updates.check_updates_branch setting.
+     *
+     * If the current major version digit matches the latest branch
+     * major version digit, we set the branch to `latest`,
+     * otherwise we'll check updates on the `stable` branch.
+     *
+     * No update required for the dev version.
+     *
+     * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
+     *
+     * FIXME! This needs to be removed when we switch to first digit major version
+     *        instead of the second one since the versionning process will change.
+     */
+    public function updateMethodCheckUpdateRemoteBranch()
+    {
+        if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
+            return true;
+        }
+
+        // Get latest branch major version digit
+        $latestVersion = ApplicationUtils::getLatestGitVersionCode(
+            'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
+            5
+        );
+        if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
+            return false;
+        }
+        $latestMajor = $matches[1];
+
+        // Get current major version digit
+        preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
+        $currentMajor = $matches[1];
+
+        if ($currentMajor === $latestMajor) {
+            $branch = 'latest';
+        } else {
+            $branch = 'stable';
+        }
+        $this->conf->set('updates.check_updates_branch', $branch);
+        $this->conf->write($this->isLoggedIn);
+        return true;
+    }
+
+    /**
+     * Reset history store file due to date format change.
+     */
+    public function updateMethodResetHistoryFile()
+    {
+        if (is_file($this->conf->get('resource.history'))) {
+            unlink($this->conf->get('resource.history'));
+        }
+        return true;
+    }
+
+    /**
+     * Save the datastore -> the link order is now applied when links are saved.
+     */
+    public function updateMethodReorderDatastore()
+    {
+        $this->linkDB->save($this->conf->get('resource.page_cache'));
+        return true;
+    }
+
+    /**
+     * Change privateonly session key to visibility.
+     */
+    public function updateMethodVisibilitySession()
+    {
+        if (isset($_SESSION['privateonly'])) {
+            unset($_SESSION['privateonly']);
+            $_SESSION['visibility'] = 'private';
+        }
+        return true;
+    }
+
+    /**
+     * Add download size and timeout to the configuration file
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodDownloadSizeAndTimeoutConf()
+    {
+        if ($this->conf->exists('general.download_max_size')
+            && $this->conf->exists('general.download_timeout')
+        ) {
+            return true;
+        }
+
+        if (!$this->conf->exists('general.download_max_size')) {
+            $this->conf->set('general.download_max_size', 1024 * 1024 * 4);
+        }
+
+        if (!$this->conf->exists('general.download_timeout')) {
+            $this->conf->set('general.download_timeout', 30);
+        }
+
+        $this->conf->write($this->isLoggedIn);
+        return true;
+    }
+
+    /**
+     * * Move thumbnails management to WebThumbnailer, coming with new settings.
+     */
+    public function updateMethodWebThumbnailer()
+    {
+        if ($this->conf->exists('thumbnails.mode')) {
+            return true;
+        }
+
+        $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
+        $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
+        $this->conf->set('thumbnails.width', 125);
+        $this->conf->set('thumbnails.height', 90);
+        $this->conf->remove('thumbnail');
+        $this->conf->write(true);
+
+        if ($thumbnailsEnabled) {
+            $this->session['warnings'][] = t(
+                'You have enabled or changed thumbnails mode. Please synchronize them.'
+            );
+        }
+
+        return true;
+    }
+
+    /**
+     * Set sticky = false on all links
+     *
+     * @return bool true if the update is successful, false otherwise.
+     */
+    public function updateMethodSetSticky()
+    {
+        foreach ($this->linkDB as $key => $link) {
+            if (isset($link['sticky'])) {
+                return true;
+            }
+            $link['sticky'] = false;
+            $this->linkDB[$key] = $link;
+        }
+
+        $this->linkDB->save($this->conf->get('resource.page_cache'));
+
+        return true;
+    }
+}
diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php
new file mode 100644
index 00000000..34d4f422
--- /dev/null
+++ b/application/updater/UpdaterUtils.php
@@ -0,0 +1,39 @@
+method = $method;
+        $this->previous = $previous;
+        $this->message = $this->buildMessage($message);
+    }
+
+    /**
+     * Build the exception error message.
+     *
+     * @param string $message Optional given error message.
+     *
+     * @return string The built error message.
+     */
+    private function buildMessage($message)
+    {
+        $out = '';
+        if (!empty($message)) {
+            $out .= $message . PHP_EOL;
+        }
+
+        if (!empty($this->method)) {
+            $out .= t('An error occurred while running the update ') . $this->method . PHP_EOL;
+        }
+
+        if (!empty($this->previous)) {
+            $out .= '  ' . $this->previous->getMessage();
+        }
+
+        return $out;
+    }
+}
diff --git a/composer.json b/composer.json
index 4c14e794..af763472 100644
--- a/composer.json
+++ b/composer.json
@@ -46,7 +46,9 @@
             "Shaarli\\Feed\\": "application/feed",
             "Shaarli\\Http\\": "application/http",
             "Shaarli\\Render\\": "application/render",
-            "Shaarli\\Security\\": "application/security"
+            "Shaarli\\Security\\": "application/security",
+            "Shaarli\\Updater\\": "application/updater",
+            "Shaarli\\Updater\\Exception\\": "application/updater/exception"
         }
     }
 }
diff --git a/index.php b/index.php
index 146b4457..ce0373e1 100644
--- a/index.php
+++ b/index.php
@@ -62,6 +62,7 @@ require_once 'application/config/ConfigPlugin.php';
 require_once 'application/feed/Cache.php';
 require_once 'application/http/HttpUtils.php';
 require_once 'application/http/UrlUtils.php';
+require_once 'application/updater/UpdaterUtils.php';
 require_once 'application/FileUtils.php';
 require_once 'application/History.php';
 require_once 'application/NetscapeBookmarkUtils.php';
@@ -69,7 +70,6 @@ require_once 'application/TimeZone.php';
 require_once 'application/Utils.php';
 require_once 'application/PluginManager.php';
 require_once 'application/Router.php';
-require_once 'application/Updater.php';
 
 use \Shaarli\Bookmark\Exception\LinkNotFoundException;
 use \Shaarli\Bookmark\LinkDB;
@@ -83,6 +83,7 @@ use \Shaarli\Render\ThemeUtils;
 use \Shaarli\Security\LoginManager;
 use \Shaarli\Security\SessionManager;
 use \Shaarli\Thumbnailer;
+use Shaarli\Updater\Updater;
 
 // Ensure the PHP version is supported
 try {
diff --git a/tests/Updater/DummyUpdater.php b/tests/Updater/DummyUpdater.php
deleted file mode 100644
index 3c74b4ff..00000000
--- a/tests/Updater/DummyUpdater.php
+++ /dev/null
@@ -1,70 +0,0 @@
-methods = $class->getMethods(ReflectionMethod::IS_FINAL);
-    }
-
-    /**
-     * Update method 1.
-     *
-     * @return bool true.
-     */
-    final private function updateMethodDummy1()
-    {
-        return true;
-    }
-
-    /**
-     * Update method 2.
-     *
-     * @return bool true.
-     */
-    final private function updateMethodDummy2()
-    {
-        return true;
-    }
-
-    /**
-     * Update method 3.
-     *
-     * @return bool true.
-     */
-    final private function updateMethodDummy3()
-    {
-        return true;
-    }
-
-    /**
-     * Update method 4, raise an exception.
-     *
-     * @throws Exception error.
-     */
-    final private function updateMethodException()
-    {
-        throw new Exception('whatever');
-    }
-}
diff --git a/tests/Updater/UpdaterTest.php b/tests/Updater/UpdaterTest.php
deleted file mode 100644
index f910e054..00000000
--- a/tests/Updater/UpdaterTest.php
+++ /dev/null
@@ -1,810 +0,0 @@
-conf = new ConfigManager(self::$configFile);
-    }
-
-    /**
-     * Test read_updates_file with an empty/missing file.
-     */
-    public function testReadEmptyUpdatesFile()
-    {
-        $this->assertEquals(array(), read_updates_file(''));
-        $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
-        touch($updatesFile);
-        $this->assertEquals(array(), read_updates_file($updatesFile));
-        unlink($updatesFile);
-    }
-
-    /**
-     * Test read/write updates file.
-     */
-    public function testReadWriteUpdatesFile()
-    {
-        $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
-        $updatesMethods = array('m1', 'm2', 'm3');
-
-        write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = read_updates_file($updatesFile);
-        $this->assertEquals($readMethods, $updatesMethods);
-
-        // Update
-        $updatesMethods[] = 'm4';
-        write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = read_updates_file($updatesFile);
-        $this->assertEquals($readMethods, $updatesMethods);
-        unlink($updatesFile);
-    }
-
-    /**
-     * Test errors in write_updates_file(): empty updates file.
-     *
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Updates file path is not set(.*)/
-     */
-    public function testWriteEmptyUpdatesFile()
-    {
-        write_updates_file('', array('test'));
-    }
-
-    /**
-     * Test errors in write_updates_file(): not writable updates file.
-     *
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Unable to write(.*)/
-     */
-    public function testWriteUpdatesFileNotWritable()
-    {
-        $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
-        touch($updatesFile);
-        chmod($updatesFile, 0444);
-        try {
-            @write_updates_file($updatesFile, array('test'));
-        } catch (Exception $e) {
-            unlink($updatesFile);
-            throw $e;
-        }
-    }
-
-    /**
-     * Test the update() method, with no update to run.
-     *   1. Everything already run.
-     *   2. User is logged out.
-     */
-    public function testNoUpdates()
-    {
-        $updates = array(
-            'updateMethodDummy1',
-            'updateMethodDummy2',
-            'updateMethodDummy3',
-            'updateMethodException',
-        );
-        $updater = new DummyUpdater($updates, array(), $this->conf, true);
-        $this->assertEquals(array(), $updater->update());
-
-        $updater = new DummyUpdater(array(), array(), $this->conf, false);
-        $this->assertEquals(array(), $updater->update());
-    }
-
-    /**
-     * Test the update() method, with all updates to run (except the failing one).
-     */
-    public function testUpdatesFirstTime()
-    {
-        $updates = array('updateMethodException',);
-        $expectedUpdates = array(
-            'updateMethodDummy1',
-            'updateMethodDummy2',
-            'updateMethodDummy3',
-        );
-        $updater = new DummyUpdater($updates, array(), $this->conf, true);
-        $this->assertEquals($expectedUpdates, $updater->update());
-    }
-
-    /**
-     * Test the update() method, only one update to run.
-     */
-    public function testOneUpdate()
-    {
-        $updates = array(
-            'updateMethodDummy1',
-            'updateMethodDummy3',
-            'updateMethodException',
-        );
-        $expectedUpdate = array('updateMethodDummy2');
-
-        $updater = new DummyUpdater($updates, array(), $this->conf, true);
-        $this->assertEquals($expectedUpdate, $updater->update());
-    }
-
-    /**
-     * Test Update failed.
-     *
-     * @expectedException UpdaterException
-     */
-    public function testUpdateFailed()
-    {
-        $updates = array(
-            'updateMethodDummy1',
-            'updateMethodDummy2',
-            'updateMethodDummy3',
-        );
-
-        $updater = new DummyUpdater($updates, array(), $this->conf, true);
-        $updater->update();
-    }
-
-    /**
-     * Test update mergeDeprecatedConfig:
-     *      1. init a config file.
-     *      2. init a options.php file with update value.
-     *      3. merge.
-     *      4. check updated value in config file.
-     */
-    public function testUpdateMergeDeprecatedConfig()
-    {
-        $this->conf->setConfigFile('tests/utils/config/configPhp');
-        $this->conf->reset();
-
-        $optionsFile = 'tests/Updater/options.php';
-        $options = 'conf->setConfigFile('tests/Updater/config');
-
-        // merge configs
-        $updater = new Updater(array(), array(), $this->conf, true);
-        // This writes a new config file in tests/Updater/config.php
-        $updater->updateMethodMergeDeprecatedConfigFile();
-
-        // make sure updated field is changed
-        $this->conf->reload();
-        $this->assertTrue($this->conf->get('privacy.default_private_links'));
-        $this->assertFalse(is_file($optionsFile));
-        // Delete the generated file.
-        unlink($this->conf->getConfigFileExt());
-    }
-
-    /**
-     * Test mergeDeprecatedConfig in without options file.
-     */
-    public function testMergeDeprecatedConfigNoFile()
-    {
-        $updater = new Updater(array(), array(), $this->conf, true);
-        $updater->updateMethodMergeDeprecatedConfigFile();
-
-        $this->assertEquals('root', $this->conf->get('credentials.login'));
-    }
-
-    /**
-     * Test renameDashTags update method.
-     */
-    public function testRenameDashTags()
-    {
-        $refDB = new ReferenceLinkDB();
-        $refDB->write(self::$testDatastore);
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-
-        $this->assertEmpty($linkDB->filterSearch(array('searchtags' => 'exclude')));
-        $updater = new Updater(array(), $linkDB, $this->conf, true);
-        $updater->updateMethodRenameDashTags();
-        $this->assertNotEmpty($linkDB->filterSearch(array('searchtags' =>  'exclude')));
-    }
-
-    /**
-     * Convert old PHP config file to JSON config.
-     */
-    public function testConfigToJson()
-    {
-        $configFile = 'tests/utils/config/configPhp';
-        $this->conf->setConfigFile($configFile);
-        $this->conf->reset();
-
-        // The ConfigIO is initialized with ConfigPhp.
-        $this->assertTrue($this->conf->getConfigIO() instanceof ConfigPhp);
-
-        $updater = new Updater(array(), array(), $this->conf, false);
-        $done = $updater->updateMethodConfigToJson();
-        $this->assertTrue($done);
-
-        // The ConfigIO has been updated to ConfigJson.
-        $this->assertTrue($this->conf->getConfigIO() instanceof ConfigJson);
-        $this->assertTrue(file_exists($this->conf->getConfigFileExt()));
-
-        // Check JSON config data.
-        $this->conf->reload();
-        $this->assertEquals('root', $this->conf->get('credentials.login'));
-        $this->assertEquals('lala', $this->conf->get('redirector.url'));
-        $this->assertEquals('data/datastore.php', $this->conf->get('resource.datastore'));
-        $this->assertEquals('1', $this->conf->get('plugins.WALLABAG_VERSION'));
-
-        rename($configFile . '.save.php', $configFile . '.php');
-        unlink($this->conf->getConfigFileExt());
-    }
-
-    /**
-     * Launch config conversion update with an existing JSON file => nothing to do.
-     */
-    public function testConfigToJsonNothingToDo()
-    {
-        $filetime = filemtime($this->conf->getConfigFileExt());
-        $updater = new Updater(array(), array(), $this->conf, false);
-        $done = $updater->updateMethodConfigToJson();
-        $this->assertTrue($done);
-        $expected = filemtime($this->conf->getConfigFileExt());
-        $this->assertEquals($expected, $filetime);
-    }
-
-    /**
-     * Test escapeUnescapedConfig with valid data.
-     */
-    public function testEscapeConfig()
-    {
-        $sandbox = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandbox . '.json.php');
-        $this->conf = new ConfigManager($sandbox);
-        $title = '';
-        $headerLink = '';
-        $redirectorUrl = '';
-        $this->conf->set('general.title', $title);
-        $this->conf->set('general.header_link', $headerLink);
-        $this->conf->set('redirector.url', $redirectorUrl);
-        $updater = new Updater(array(), array(), $this->conf, true);
-        $done = $updater->updateMethodEscapeUnescapedConfig();
-        $this->assertTrue($done);
-        $this->conf->reload();
-        $this->assertEquals(escape($title), $this->conf->get('general.title'));
-        $this->assertEquals(escape($headerLink), $this->conf->get('general.header_link'));
-        $this->assertEquals(escape($redirectorUrl), $this->conf->get('redirector.url'));
-        unlink($sandbox . '.json.php');
-    }
-
-    /**
-     * Test updateMethodApiSettings(): create default settings for the API (enabled + secret).
-     */
-    public function testUpdateApiSettings()
-    {
-        $confFile = 'sandbox/config';
-        copy(self::$configFile .'.json.php', $confFile .'.json.php');
-        $conf = new ConfigManager($confFile);
-        $updater = new Updater(array(), array(), $conf, true);
-
-        $this->assertFalse($conf->exists('api.enabled'));
-        $this->assertFalse($conf->exists('api.secret'));
-        $updater->updateMethodApiSettings();
-        $conf->reload();
-        $this->assertTrue($conf->get('api.enabled'));
-        $this->assertTrue($conf->exists('api.secret'));
-        unlink($confFile .'.json.php');
-    }
-
-    /**
-     * Test updateMethodApiSettings(): already set, do nothing.
-     */
-    public function testUpdateApiSettingsNothingToDo()
-    {
-        $confFile = 'sandbox/config';
-        copy(self::$configFile .'.json.php', $confFile .'.json.php');
-        $conf = new ConfigManager($confFile);
-        $conf->set('api.enabled', false);
-        $conf->set('api.secret', '');
-        $updater = new Updater(array(), array(), $conf, true);
-        $updater->updateMethodApiSettings();
-        $this->assertFalse($conf->get('api.enabled'));
-        $this->assertEmpty($conf->get('api.secret'));
-        unlink($confFile .'.json.php');
-    }
-
-    /**
-     * Test updateMethodDatastoreIds().
-     */
-    public function testDatastoreIds()
-    {
-        $links = array(
-            '20121206_182539' => array(
-                'linkdate' => '20121206_182539',
-                'title' => 'Geek and Poke',
-                'url' => 'http://geek-and-poke.com/',
-                'description' => 'desc',
-                'tags' => 'dev cartoon tag1  tag2   tag3  tag4   ',
-                'updated' => '20121206_190301',
-                'private' => false,
-            ),
-            '20121206_172539' => array(
-                'linkdate' => '20121206_172539',
-                'title' => 'UserFriendly - Samba',
-                'url' => 'http://ars.userfriendly.org/cartoons/?id=20010306',
-                'description' => '',
-                'tags' => 'samba cartoon web',
-                'private' => false,
-            ),
-            '20121206_142300' => array(
-                'linkdate' => '20121206_142300',
-                'title' => 'UserFriendly - Web Designer',
-                'url' => 'http://ars.userfriendly.org/cartoons/?id=20121206',
-                'description' => 'Naming conventions... #private',
-                'tags' => 'samba cartoon web',
-                'private' => true,
-            ),
-        );
-        $refDB = new ReferenceLinkDB();
-        $refDB->setLinks($links);
-        $refDB->write(self::$testDatastore);
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-
-        $checksum = hash_file('sha1', self::$testDatastore);
-
-        $this->conf->set('resource.data_dir', 'sandbox');
-        $this->conf->set('resource.datastore', self::$testDatastore);
-
-        $updater = new Updater(array(), $linkDB, $this->conf, true);
-        $this->assertTrue($updater->updateMethodDatastoreIds());
-
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-
-        $backup = glob($this->conf->get('resource.data_dir') . '/datastore.'. date('YmdH') .'*.php');
-        $backup = $backup[0];
-
-        $this->assertFileExists($backup);
-        $this->assertEquals($checksum, hash_file('sha1', $backup));
-        unlink($backup);
-
-        $this->assertEquals(3, count($linkDB));
-        $this->assertTrue(isset($linkDB[0]));
-        $this->assertFalse(isset($linkDB[0]['linkdate']));
-        $this->assertEquals(0, $linkDB[0]['id']);
-        $this->assertEquals('UserFriendly - Web Designer', $linkDB[0]['title']);
-        $this->assertEquals('http://ars.userfriendly.org/cartoons/?id=20121206', $linkDB[0]['url']);
-        $this->assertEquals('Naming conventions... #private', $linkDB[0]['description']);
-        $this->assertEquals('samba cartoon web', $linkDB[0]['tags']);
-        $this->assertTrue($linkDB[0]['private']);
-        $this->assertEquals(
-            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_142300'),
-            $linkDB[0]['created']
-        );
-
-        $this->assertTrue(isset($linkDB[1]));
-        $this->assertFalse(isset($linkDB[1]['linkdate']));
-        $this->assertEquals(1, $linkDB[1]['id']);
-        $this->assertEquals('UserFriendly - Samba', $linkDB[1]['title']);
-        $this->assertEquals(
-            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_172539'),
-            $linkDB[1]['created']
-        );
-
-        $this->assertTrue(isset($linkDB[2]));
-        $this->assertFalse(isset($linkDB[2]['linkdate']));
-        $this->assertEquals(2, $linkDB[2]['id']);
-        $this->assertEquals('Geek and Poke', $linkDB[2]['title']);
-        $this->assertEquals(
-            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_182539'),
-            $linkDB[2]['created']
-        );
-        $this->assertEquals(
-            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_190301'),
-            $linkDB[2]['updated']
-        );
-    }
-
-    /**
-     * Test updateMethodDatastoreIds() with the update already applied: nothing to do.
-     */
-    public function testDatastoreIdsNothingToDo()
-    {
-        $refDB = new ReferenceLinkDB();
-        $refDB->write(self::$testDatastore);
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-
-        $this->conf->set('resource.data_dir', 'sandbox');
-        $this->conf->set('resource.datastore', self::$testDatastore);
-
-        $checksum = hash_file('sha1', self::$testDatastore);
-        $updater = new Updater(array(), $linkDB, $this->conf, true);
-        $this->assertTrue($updater->updateMethodDatastoreIds());
-        $this->assertEquals($checksum, hash_file('sha1', self::$testDatastore));
-    }
-
-    /**
-     * Test defaultTheme update with default settings: nothing to do.
-     */
-    public function testDefaultThemeWithDefaultSettings()
-    {
-        $sandbox = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandbox . '.json.php');
-        $this->conf = new ConfigManager($sandbox);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodDefaultTheme());
-
-        $this->assertEquals('tpl/', $this->conf->get('resource.raintpl_tpl'));
-        $this->assertEquals('default', $this->conf->get('resource.theme'));
-        $this->conf = new ConfigManager($sandbox);
-        $this->assertEquals('tpl/', $this->conf->get('resource.raintpl_tpl'));
-        $this->assertEquals('default', $this->conf->get('resource.theme'));
-        unlink($sandbox . '.json.php');
-    }
-
-    /**
-     * Test defaultTheme update with a custom theme in a subfolder
-     */
-    public function testDefaultThemeWithCustomTheme()
-    {
-        $theme = 'iamanartist';
-        $sandbox = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandbox . '.json.php');
-        $this->conf = new ConfigManager($sandbox);
-        mkdir('sandbox/'. $theme);
-        touch('sandbox/'. $theme .'/linklist.html');
-        $this->conf->set('resource.raintpl_tpl', 'sandbox/'. $theme .'/');
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodDefaultTheme());
-
-        $this->assertEquals('sandbox', $this->conf->get('resource.raintpl_tpl'));
-        $this->assertEquals($theme, $this->conf->get('resource.theme'));
-        $this->conf = new ConfigManager($sandbox);
-        $this->assertEquals('sandbox', $this->conf->get('resource.raintpl_tpl'));
-        $this->assertEquals($theme, $this->conf->get('resource.theme'));
-        unlink($sandbox . '.json.php');
-        unlink('sandbox/'. $theme .'/linklist.html');
-        rmdir('sandbox/'. $theme);
-    }
-
-    /**
-     * Test updateMethodEscapeMarkdown with markdown plugin enabled
-     * => setting markdown_escape set to false.
-     */
-    public function testEscapeMarkdownSettingToFalse()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-
-        $this->conf->set('general.enabled_plugins', ['markdown']);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodEscapeMarkdown());
-        $this->assertFalse($this->conf->get('security.markdown_escape'));
-
-        // reload from file
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->assertFalse($this->conf->get('security.markdown_escape'));
-    }
-
-
-    /**
-     * Test updateMethodEscapeMarkdown with markdown plugin disabled
-     * => setting markdown_escape set to true.
-     */
-    public function testEscapeMarkdownSettingToTrue()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-
-        $this->conf->set('general.enabled_plugins', []);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodEscapeMarkdown());
-        $this->assertTrue($this->conf->get('security.markdown_escape'));
-
-        // reload from file
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->assertTrue($this->conf->get('security.markdown_escape'));
-    }
-
-    /**
-     * Test updateMethodEscapeMarkdown with nothing to do (setting already enabled)
-     */
-    public function testEscapeMarkdownSettingNothingToDoEnabled()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->conf->set('security.markdown_escape', true);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodEscapeMarkdown());
-        $this->assertTrue($this->conf->get('security.markdown_escape'));
-    }
-
-    /**
-     * Test updateMethodEscapeMarkdown with nothing to do (setting already disabled)
-     */
-    public function testEscapeMarkdownSettingNothingToDoDisabled()
-    {
-        $this->conf->set('security.markdown_escape', false);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodEscapeMarkdown());
-        $this->assertFalse($this->conf->get('security.markdown_escape'));
-    }
-
-    /**
-     * Test updateMethodPiwikUrl with valid data
-     */
-    public function testUpdatePiwikUrlValid()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $url = 'mypiwik.tld';
-        $this->conf->set('plugins.PIWIK_URL', $url);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodPiwikUrl());
-        $this->assertEquals('http://'. $url, $this->conf->get('plugins.PIWIK_URL'));
-
-        // reload from file
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->assertEquals('http://'. $url, $this->conf->get('plugins.PIWIK_URL'));
-    }
-
-    /**
-     * Test updateMethodPiwikUrl without setting
-     */
-    public function testUpdatePiwikUrlEmpty()
-    {
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodPiwikUrl());
-        $this->assertEmpty($this->conf->get('plugins.PIWIK_URL'));
-    }
-
-    /**
-     * Test updateMethodPiwikUrl: valid URL, nothing to do
-     */
-    public function testUpdatePiwikUrlNothingToDo()
-    {
-        $url = 'https://mypiwik.tld';
-        $this->conf->set('plugins.PIWIK_URL', $url);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodPiwikUrl());
-        $this->assertEquals($url, $this->conf->get('plugins.PIWIK_URL'));
-    }
-
-    /**
-     * Test updateMethodAtomDefault with show_atom set to false
-     * => update to true.
-     */
-    public function testUpdateMethodAtomDefault()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->conf->set('feed.show_atom', false);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodAtomDefault());
-        $this->assertTrue($this->conf->get('feed.show_atom'));
-        // reload from file
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->assertTrue($this->conf->get('feed.show_atom'));
-    }
-    /**
-     * Test updateMethodAtomDefault with show_atom not set.
-     * => nothing to do
-     */
-    public function testUpdateMethodAtomDefaultNoExist()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodAtomDefault());
-        $this->assertTrue($this->conf->get('feed.show_atom'));
-    }
-    /**
-     * Test updateMethodAtomDefault with show_atom set to true.
-     * => nothing to do
-     */
-    public function testUpdateMethodAtomDefaultAlreadyTrue()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->conf->set('feed.show_atom', true);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodAtomDefault());
-        $this->assertTrue($this->conf->get('feed.show_atom'));
-    }
-
-    /**
-     * Test updateMethodDownloadSizeAndTimeoutConf, it should be set if none is already defined.
-     */
-    public function testUpdateMethodDownloadSizeAndTimeoutConf()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
-        $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
-        $this->assertEquals(30, $this->conf->get('general.download_timeout'));
-
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
-        $this->assertEquals(30, $this->conf->get('general.download_timeout'));
-    }
-
-    /**
-     * Test updateMethodDownloadSizeAndTimeoutConf, it shouldn't be set if it is already defined.
-     */
-    public function testUpdateMethodDownloadSizeAndTimeoutConfIgnore()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->conf->set('general.download_max_size', 38);
-        $this->conf->set('general.download_timeout', 70);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
-        $this->assertEquals(38, $this->conf->get('general.download_max_size'));
-        $this->assertEquals(70, $this->conf->get('general.download_timeout'));
-    }
-
-    /**
-     * Test updateMethodDownloadSizeAndTimeoutConf, only the maz size should be set here.
-     */
-    public function testUpdateMethodDownloadSizeAndTimeoutConfOnlySize()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->conf->set('general.download_max_size', 38);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
-        $this->assertEquals(38, $this->conf->get('general.download_max_size'));
-        $this->assertEquals(30, $this->conf->get('general.download_timeout'));
-    }
-
-    /**
-     * Test updateMethodDownloadSizeAndTimeoutConf, only the time out should be set here.
-     */
-    public function testUpdateMethodDownloadSizeAndTimeoutConfOnlyTimeout()
-    {
-        $sandboxConf = 'sandbox/config';
-        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
-        $this->conf = new ConfigManager($sandboxConf);
-        $this->conf->set('general.download_timeout', 3);
-        $updater = new Updater([], [], $this->conf, true);
-        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
-        $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
-        $this->assertEquals(3, $this->conf->get('general.download_timeout'));
-    }
-
-    /**
-<<<<<<< HEAD
-     * Test updateMethodWebThumbnailer with thumbnails enabled.
-     */
-    public function testUpdateMethodWebThumbnailerEnabled()
-    {
-        $this->conf->remove('thumbnails');
-        $this->conf->set('thumbnail.enable_thumbnails', true);
-        $updater = new Updater([], [], $this->conf, true, $_SESSION);
-        $this->assertTrue($updater->updateMethodWebThumbnailer());
-        $this->assertFalse($this->conf->exists('thumbnail'));
-        $this->assertEquals(\Shaarli\Thumbnailer::MODE_ALL, $this->conf->get('thumbnails.mode'));
-        $this->assertEquals(125, $this->conf->get('thumbnails.width'));
-        $this->assertEquals(90, $this->conf->get('thumbnails.height'));
-        $this->assertContains('You have enabled or changed thumbnails', $_SESSION['warnings'][0]);
-    }
-
-    /**
-     * Test updateMethodWebThumbnailer with thumbnails disabled.
-     */
-    public function testUpdateMethodWebThumbnailerDisabled()
-    {
-        $this->conf->remove('thumbnails');
-        $this->conf->set('thumbnail.enable_thumbnails', false);
-        $updater = new Updater([], [], $this->conf, true, $_SESSION);
-        $this->assertTrue($updater->updateMethodWebThumbnailer());
-        $this->assertFalse($this->conf->exists('thumbnail'));
-        $this->assertEquals(Thumbnailer::MODE_NONE, $this->conf->get('thumbnails.mode'));
-        $this->assertEquals(125, $this->conf->get('thumbnails.width'));
-        $this->assertEquals(90, $this->conf->get('thumbnails.height'));
-        $this->assertTrue(empty($_SESSION['warnings']));
-    }
-
-    /**
-     * Test updateMethodWebThumbnailer with thumbnails disabled.
-     */
-    public function testUpdateMethodWebThumbnailerNothingToDo()
-    {
-        $updater = new Updater([], [], $this->conf, true, $_SESSION);
-        $this->assertTrue($updater->updateMethodWebThumbnailer());
-        $this->assertFalse($this->conf->exists('thumbnail'));
-        $this->assertEquals(Thumbnailer::MODE_COMMON, $this->conf->get('thumbnails.mode'));
-        $this->assertEquals(90, $this->conf->get('thumbnails.width'));
-        $this->assertEquals(53, $this->conf->get('thumbnails.height'));
-        $this->assertTrue(empty($_SESSION['warnings']));
-    }
-
-    /**
-     * Test updateMethodSetSticky().
-     */
-    public function testUpdateStickyValid()
-    {
-        $blank = [
-            'id' => 1,
-            'url' => 'z',
-            'title' => '',
-            'description' => '',
-            'tags' => '',
-            'created' => new DateTime(),
-        ];
-        $links = [
-            1 => ['id' => 1] + $blank,
-            2 => ['id' => 2] + $blank,
-        ];
-        $refDB = new ReferenceLinkDB();
-        $refDB->setLinks($links);
-        $refDB->write(self::$testDatastore);
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-
-        $updater = new Updater(array(), $linkDB, $this->conf, true);
-        $this->assertTrue($updater->updateMethodSetSticky());
-
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-        foreach ($linkDB as $link) {
-            $this->assertFalse($link['sticky']);
-        }
-    }
-
-    /**
-     * Test updateMethodSetSticky().
-     */
-    public function testUpdateStickyNothingToDo()
-    {
-        $blank = [
-            'id' => 1,
-            'url' => 'z',
-            'title' => '',
-            'description' => '',
-            'tags' => '',
-            'created' => new DateTime(),
-        ];
-        $links = [
-            1 => ['id' => 1, 'sticky' => true] + $blank,
-            2 => ['id' => 2] + $blank,
-        ];
-        $refDB = new ReferenceLinkDB();
-        $refDB->setLinks($links);
-        $refDB->write(self::$testDatastore);
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-
-        $updater = new Updater(array(), $linkDB, $this->conf, true);
-        $this->assertTrue($updater->updateMethodSetSticky());
-
-        $linkDB = new LinkDB(self::$testDatastore, true, false);
-        $this->assertTrue($linkDB[1]['sticky']);
-    }
-}
diff --git a/tests/updater/DummyUpdater.php b/tests/updater/DummyUpdater.php
new file mode 100644
index 00000000..9e866f1f
--- /dev/null
+++ b/tests/updater/DummyUpdater.php
@@ -0,0 +1,73 @@
+methods = $class->getMethods(ReflectionMethod::IS_FINAL);
+    }
+
+    /**
+     * Update method 1.
+     *
+     * @return bool true.
+     */
+    final private function updateMethodDummy1()
+    {
+        return true;
+    }
+
+    /**
+     * Update method 2.
+     *
+     * @return bool true.
+     */
+    final private function updateMethodDummy2()
+    {
+        return true;
+    }
+
+    /**
+     * Update method 3.
+     *
+     * @return bool true.
+     */
+    final private function updateMethodDummy3()
+    {
+        return true;
+    }
+
+    /**
+     * Update method 4, raise an exception.
+     *
+     * @throws Exception error.
+     */
+    final private function updateMethodException()
+    {
+        throw new Exception('whatever');
+    }
+}
diff --git a/tests/updater/UpdaterTest.php b/tests/updater/UpdaterTest.php
new file mode 100644
index 00000000..d7df5963
--- /dev/null
+++ b/tests/updater/UpdaterTest.php
@@ -0,0 +1,815 @@
+conf = new ConfigManager(self::$configFile);
+    }
+
+    /**
+     * Test read_updates_file with an empty/missing file.
+     */
+    public function testReadEmptyUpdatesFile()
+    {
+        $this->assertEquals(array(), read_updates_file(''));
+        $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
+        touch($updatesFile);
+        $this->assertEquals(array(), read_updates_file($updatesFile));
+        unlink($updatesFile);
+    }
+
+    /**
+     * Test read/write updates file.
+     */
+    public function testReadWriteUpdatesFile()
+    {
+        $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
+        $updatesMethods = array('m1', 'm2', 'm3');
+
+        write_updates_file($updatesFile, $updatesMethods);
+        $readMethods = read_updates_file($updatesFile);
+        $this->assertEquals($readMethods, $updatesMethods);
+
+        // Update
+        $updatesMethods[] = 'm4';
+        write_updates_file($updatesFile, $updatesMethods);
+        $readMethods = read_updates_file($updatesFile);
+        $this->assertEquals($readMethods, $updatesMethods);
+        unlink($updatesFile);
+    }
+
+    /**
+     * Test errors in write_updates_file(): empty updates file.
+     *
+     * @expectedException              Exception
+     * @expectedExceptionMessageRegExp /Updates file path is not set(.*)/
+     */
+    public function testWriteEmptyUpdatesFile()
+    {
+        write_updates_file('', array('test'));
+    }
+
+    /**
+     * Test errors in write_updates_file(): not writable updates file.
+     *
+     * @expectedException              Exception
+     * @expectedExceptionMessageRegExp /Unable to write(.*)/
+     */
+    public function testWriteUpdatesFileNotWritable()
+    {
+        $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
+        touch($updatesFile);
+        chmod($updatesFile, 0444);
+        try {
+            @write_updates_file($updatesFile, array('test'));
+        } catch (Exception $e) {
+            unlink($updatesFile);
+            throw $e;
+        }
+    }
+
+    /**
+     * Test the update() method, with no update to run.
+     *   1. Everything already run.
+     *   2. User is logged out.
+     */
+    public function testNoUpdates()
+    {
+        $updates = array(
+            'updateMethodDummy1',
+            'updateMethodDummy2',
+            'updateMethodDummy3',
+            'updateMethodException',
+        );
+        $updater = new DummyUpdater($updates, array(), $this->conf, true);
+        $this->assertEquals(array(), $updater->update());
+
+        $updater = new DummyUpdater(array(), array(), $this->conf, false);
+        $this->assertEquals(array(), $updater->update());
+    }
+
+    /**
+     * Test the update() method, with all updates to run (except the failing one).
+     */
+    public function testUpdatesFirstTime()
+    {
+        $updates = array('updateMethodException',);
+        $expectedUpdates = array(
+            'updateMethodDummy1',
+            'updateMethodDummy2',
+            'updateMethodDummy3',
+        );
+        $updater = new DummyUpdater($updates, array(), $this->conf, true);
+        $this->assertEquals($expectedUpdates, $updater->update());
+    }
+
+    /**
+     * Test the update() method, only one update to run.
+     */
+    public function testOneUpdate()
+    {
+        $updates = array(
+            'updateMethodDummy1',
+            'updateMethodDummy3',
+            'updateMethodException',
+        );
+        $expectedUpdate = array('updateMethodDummy2');
+
+        $updater = new DummyUpdater($updates, array(), $this->conf, true);
+        $this->assertEquals($expectedUpdate, $updater->update());
+    }
+
+    /**
+     * Test Update failed.
+     *
+     * @expectedException \Exception
+     */
+    public function testUpdateFailed()
+    {
+        $updates = array(
+            'updateMethodDummy1',
+            'updateMethodDummy2',
+            'updateMethodDummy3',
+        );
+
+        $updater = new DummyUpdater($updates, array(), $this->conf, true);
+        $updater->update();
+    }
+
+    /**
+     * Test update mergeDeprecatedConfig:
+     *      1. init a config file.
+     *      2. init a options.php file with update value.
+     *      3. merge.
+     *      4. check updated value in config file.
+     */
+    public function testUpdateMergeDeprecatedConfig()
+    {
+        $this->conf->setConfigFile('tests/utils/config/configPhp');
+        $this->conf->reset();
+
+        $optionsFile = 'tests/updater/options.php';
+        $options = 'conf->setConfigFile('tests/updater/config');
+
+        // merge configs
+        $updater = new Updater(array(), array(), $this->conf, true);
+        // This writes a new config file in tests/updater/config.php
+        $updater->updateMethodMergeDeprecatedConfigFile();
+
+        // make sure updated field is changed
+        $this->conf->reload();
+        $this->assertTrue($this->conf->get('privacy.default_private_links'));
+        $this->assertFalse(is_file($optionsFile));
+        // Delete the generated file.
+        unlink($this->conf->getConfigFileExt());
+    }
+
+    /**
+     * Test mergeDeprecatedConfig in without options file.
+     */
+    public function testMergeDeprecatedConfigNoFile()
+    {
+        $updater = new Updater(array(), array(), $this->conf, true);
+        $updater->updateMethodMergeDeprecatedConfigFile();
+
+        $this->assertEquals('root', $this->conf->get('credentials.login'));
+    }
+
+    /**
+     * Test renameDashTags update method.
+     */
+    public function testRenameDashTags()
+    {
+        $refDB = new \ReferenceLinkDB();
+        $refDB->write(self::$testDatastore);
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+
+        $this->assertEmpty($linkDB->filterSearch(array('searchtags' => 'exclude')));
+        $updater = new Updater(array(), $linkDB, $this->conf, true);
+        $updater->updateMethodRenameDashTags();
+        $this->assertNotEmpty($linkDB->filterSearch(array('searchtags' =>  'exclude')));
+    }
+
+    /**
+     * Convert old PHP config file to JSON config.
+     */
+    public function testConfigToJson()
+    {
+        $configFile = 'tests/utils/config/configPhp';
+        $this->conf->setConfigFile($configFile);
+        $this->conf->reset();
+
+        // The ConfigIO is initialized with ConfigPhp.
+        $this->assertTrue($this->conf->getConfigIO() instanceof ConfigPhp);
+
+        $updater = new Updater(array(), array(), $this->conf, false);
+        $done = $updater->updateMethodConfigToJson();
+        $this->assertTrue($done);
+
+        // The ConfigIO has been updated to ConfigJson.
+        $this->assertTrue($this->conf->getConfigIO() instanceof ConfigJson);
+        $this->assertTrue(file_exists($this->conf->getConfigFileExt()));
+
+        // Check JSON config data.
+        $this->conf->reload();
+        $this->assertEquals('root', $this->conf->get('credentials.login'));
+        $this->assertEquals('lala', $this->conf->get('redirector.url'));
+        $this->assertEquals('data/datastore.php', $this->conf->get('resource.datastore'));
+        $this->assertEquals('1', $this->conf->get('plugins.WALLABAG_VERSION'));
+
+        rename($configFile . '.save.php', $configFile . '.php');
+        unlink($this->conf->getConfigFileExt());
+    }
+
+    /**
+     * Launch config conversion update with an existing JSON file => nothing to do.
+     */
+    public function testConfigToJsonNothingToDo()
+    {
+        $filetime = filemtime($this->conf->getConfigFileExt());
+        $updater = new Updater(array(), array(), $this->conf, false);
+        $done = $updater->updateMethodConfigToJson();
+        $this->assertTrue($done);
+        $expected = filemtime($this->conf->getConfigFileExt());
+        $this->assertEquals($expected, $filetime);
+    }
+
+    /**
+     * Test escapeUnescapedConfig with valid data.
+     */
+    public function testEscapeConfig()
+    {
+        $sandbox = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandbox . '.json.php');
+        $this->conf = new ConfigManager($sandbox);
+        $title = '';
+        $headerLink = '';
+        $redirectorUrl = '';
+        $this->conf->set('general.title', $title);
+        $this->conf->set('general.header_link', $headerLink);
+        $this->conf->set('redirector.url', $redirectorUrl);
+        $updater = new Updater(array(), array(), $this->conf, true);
+        $done = $updater->updateMethodEscapeUnescapedConfig();
+        $this->assertTrue($done);
+        $this->conf->reload();
+        $this->assertEquals(escape($title), $this->conf->get('general.title'));
+        $this->assertEquals(escape($headerLink), $this->conf->get('general.header_link'));
+        $this->assertEquals(escape($redirectorUrl), $this->conf->get('redirector.url'));
+        unlink($sandbox . '.json.php');
+    }
+
+    /**
+     * Test updateMethodApiSettings(): create default settings for the API (enabled + secret).
+     */
+    public function testUpdateApiSettings()
+    {
+        $confFile = 'sandbox/config';
+        copy(self::$configFile .'.json.php', $confFile .'.json.php');
+        $conf = new ConfigManager($confFile);
+        $updater = new Updater(array(), array(), $conf, true);
+
+        $this->assertFalse($conf->exists('api.enabled'));
+        $this->assertFalse($conf->exists('api.secret'));
+        $updater->updateMethodApiSettings();
+        $conf->reload();
+        $this->assertTrue($conf->get('api.enabled'));
+        $this->assertTrue($conf->exists('api.secret'));
+        unlink($confFile .'.json.php');
+    }
+
+    /**
+     * Test updateMethodApiSettings(): already set, do nothing.
+     */
+    public function testUpdateApiSettingsNothingToDo()
+    {
+        $confFile = 'sandbox/config';
+        copy(self::$configFile .'.json.php', $confFile .'.json.php');
+        $conf = new ConfigManager($confFile);
+        $conf->set('api.enabled', false);
+        $conf->set('api.secret', '');
+        $updater = new Updater(array(), array(), $conf, true);
+        $updater->updateMethodApiSettings();
+        $this->assertFalse($conf->get('api.enabled'));
+        $this->assertEmpty($conf->get('api.secret'));
+        unlink($confFile .'.json.php');
+    }
+
+    /**
+     * Test updateMethodDatastoreIds().
+     */
+    public function testDatastoreIds()
+    {
+        $links = array(
+            '20121206_182539' => array(
+                'linkdate' => '20121206_182539',
+                'title' => 'Geek and Poke',
+                'url' => 'http://geek-and-poke.com/',
+                'description' => 'desc',
+                'tags' => 'dev cartoon tag1  tag2   tag3  tag4   ',
+                'updated' => '20121206_190301',
+                'private' => false,
+            ),
+            '20121206_172539' => array(
+                'linkdate' => '20121206_172539',
+                'title' => 'UserFriendly - Samba',
+                'url' => 'http://ars.userfriendly.org/cartoons/?id=20010306',
+                'description' => '',
+                'tags' => 'samba cartoon web',
+                'private' => false,
+            ),
+            '20121206_142300' => array(
+                'linkdate' => '20121206_142300',
+                'title' => 'UserFriendly - Web Designer',
+                'url' => 'http://ars.userfriendly.org/cartoons/?id=20121206',
+                'description' => 'Naming conventions... #private',
+                'tags' => 'samba cartoon web',
+                'private' => true,
+            ),
+        );
+        $refDB = new \ReferenceLinkDB();
+        $refDB->setLinks($links);
+        $refDB->write(self::$testDatastore);
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+
+        $checksum = hash_file('sha1', self::$testDatastore);
+
+        $this->conf->set('resource.data_dir', 'sandbox');
+        $this->conf->set('resource.datastore', self::$testDatastore);
+
+        $updater = new Updater(array(), $linkDB, $this->conf, true);
+        $this->assertTrue($updater->updateMethodDatastoreIds());
+
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+
+        $backup = glob($this->conf->get('resource.data_dir') . '/datastore.'. date('YmdH') .'*.php');
+        $backup = $backup[0];
+
+        $this->assertFileExists($backup);
+        $this->assertEquals($checksum, hash_file('sha1', $backup));
+        unlink($backup);
+
+        $this->assertEquals(3, count($linkDB));
+        $this->assertTrue(isset($linkDB[0]));
+        $this->assertFalse(isset($linkDB[0]['linkdate']));
+        $this->assertEquals(0, $linkDB[0]['id']);
+        $this->assertEquals('UserFriendly - Web Designer', $linkDB[0]['title']);
+        $this->assertEquals('http://ars.userfriendly.org/cartoons/?id=20121206', $linkDB[0]['url']);
+        $this->assertEquals('Naming conventions... #private', $linkDB[0]['description']);
+        $this->assertEquals('samba cartoon web', $linkDB[0]['tags']);
+        $this->assertTrue($linkDB[0]['private']);
+        $this->assertEquals(
+            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_142300'),
+            $linkDB[0]['created']
+        );
+
+        $this->assertTrue(isset($linkDB[1]));
+        $this->assertFalse(isset($linkDB[1]['linkdate']));
+        $this->assertEquals(1, $linkDB[1]['id']);
+        $this->assertEquals('UserFriendly - Samba', $linkDB[1]['title']);
+        $this->assertEquals(
+            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_172539'),
+            $linkDB[1]['created']
+        );
+
+        $this->assertTrue(isset($linkDB[2]));
+        $this->assertFalse(isset($linkDB[2]['linkdate']));
+        $this->assertEquals(2, $linkDB[2]['id']);
+        $this->assertEquals('Geek and Poke', $linkDB[2]['title']);
+        $this->assertEquals(
+            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_182539'),
+            $linkDB[2]['created']
+        );
+        $this->assertEquals(
+            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_190301'),
+            $linkDB[2]['updated']
+        );
+    }
+
+    /**
+     * Test updateMethodDatastoreIds() with the update already applied: nothing to do.
+     */
+    public function testDatastoreIdsNothingToDo()
+    {
+        $refDB = new \ReferenceLinkDB();
+        $refDB->write(self::$testDatastore);
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+
+        $this->conf->set('resource.data_dir', 'sandbox');
+        $this->conf->set('resource.datastore', self::$testDatastore);
+
+        $checksum = hash_file('sha1', self::$testDatastore);
+        $updater = new Updater(array(), $linkDB, $this->conf, true);
+        $this->assertTrue($updater->updateMethodDatastoreIds());
+        $this->assertEquals($checksum, hash_file('sha1', self::$testDatastore));
+    }
+
+    /**
+     * Test defaultTheme update with default settings: nothing to do.
+     */
+    public function testDefaultThemeWithDefaultSettings()
+    {
+        $sandbox = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandbox . '.json.php');
+        $this->conf = new ConfigManager($sandbox);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodDefaultTheme());
+
+        $this->assertEquals('tpl/', $this->conf->get('resource.raintpl_tpl'));
+        $this->assertEquals('default', $this->conf->get('resource.theme'));
+        $this->conf = new ConfigManager($sandbox);
+        $this->assertEquals('tpl/', $this->conf->get('resource.raintpl_tpl'));
+        $this->assertEquals('default', $this->conf->get('resource.theme'));
+        unlink($sandbox . '.json.php');
+    }
+
+    /**
+     * Test defaultTheme update with a custom theme in a subfolder
+     */
+    public function testDefaultThemeWithCustomTheme()
+    {
+        $theme = 'iamanartist';
+        $sandbox = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandbox . '.json.php');
+        $this->conf = new ConfigManager($sandbox);
+        mkdir('sandbox/'. $theme);
+        touch('sandbox/'. $theme .'/linklist.html');
+        $this->conf->set('resource.raintpl_tpl', 'sandbox/'. $theme .'/');
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodDefaultTheme());
+
+        $this->assertEquals('sandbox', $this->conf->get('resource.raintpl_tpl'));
+        $this->assertEquals($theme, $this->conf->get('resource.theme'));
+        $this->conf = new ConfigManager($sandbox);
+        $this->assertEquals('sandbox', $this->conf->get('resource.raintpl_tpl'));
+        $this->assertEquals($theme, $this->conf->get('resource.theme'));
+        unlink($sandbox . '.json.php');
+        unlink('sandbox/'. $theme .'/linklist.html');
+        rmdir('sandbox/'. $theme);
+    }
+
+    /**
+     * Test updateMethodEscapeMarkdown with markdown plugin enabled
+     * => setting markdown_escape set to false.
+     */
+    public function testEscapeMarkdownSettingToFalse()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+
+        $this->conf->set('general.enabled_plugins', ['markdown']);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodEscapeMarkdown());
+        $this->assertFalse($this->conf->get('security.markdown_escape'));
+
+        // reload from file
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->assertFalse($this->conf->get('security.markdown_escape'));
+    }
+
+
+    /**
+     * Test updateMethodEscapeMarkdown with markdown plugin disabled
+     * => setting markdown_escape set to true.
+     */
+    public function testEscapeMarkdownSettingToTrue()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+
+        $this->conf->set('general.enabled_plugins', []);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodEscapeMarkdown());
+        $this->assertTrue($this->conf->get('security.markdown_escape'));
+
+        // reload from file
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->assertTrue($this->conf->get('security.markdown_escape'));
+    }
+
+    /**
+     * Test updateMethodEscapeMarkdown with nothing to do (setting already enabled)
+     */
+    public function testEscapeMarkdownSettingNothingToDoEnabled()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->conf->set('security.markdown_escape', true);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodEscapeMarkdown());
+        $this->assertTrue($this->conf->get('security.markdown_escape'));
+    }
+
+    /**
+     * Test updateMethodEscapeMarkdown with nothing to do (setting already disabled)
+     */
+    public function testEscapeMarkdownSettingNothingToDoDisabled()
+    {
+        $this->conf->set('security.markdown_escape', false);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodEscapeMarkdown());
+        $this->assertFalse($this->conf->get('security.markdown_escape'));
+    }
+
+    /**
+     * Test updateMethodPiwikUrl with valid data
+     */
+    public function testUpdatePiwikUrlValid()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $url = 'mypiwik.tld';
+        $this->conf->set('plugins.PIWIK_URL', $url);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodPiwikUrl());
+        $this->assertEquals('http://'. $url, $this->conf->get('plugins.PIWIK_URL'));
+
+        // reload from file
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->assertEquals('http://'. $url, $this->conf->get('plugins.PIWIK_URL'));
+    }
+
+    /**
+     * Test updateMethodPiwikUrl without setting
+     */
+    public function testUpdatePiwikUrlEmpty()
+    {
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodPiwikUrl());
+        $this->assertEmpty($this->conf->get('plugins.PIWIK_URL'));
+    }
+
+    /**
+     * Test updateMethodPiwikUrl: valid URL, nothing to do
+     */
+    public function testUpdatePiwikUrlNothingToDo()
+    {
+        $url = 'https://mypiwik.tld';
+        $this->conf->set('plugins.PIWIK_URL', $url);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodPiwikUrl());
+        $this->assertEquals($url, $this->conf->get('plugins.PIWIK_URL'));
+    }
+
+    /**
+     * Test updateMethodAtomDefault with show_atom set to false
+     * => update to true.
+     */
+    public function testUpdateMethodAtomDefault()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->conf->set('feed.show_atom', false);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodAtomDefault());
+        $this->assertTrue($this->conf->get('feed.show_atom'));
+        // reload from file
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->assertTrue($this->conf->get('feed.show_atom'));
+    }
+    /**
+     * Test updateMethodAtomDefault with show_atom not set.
+     * => nothing to do
+     */
+    public function testUpdateMethodAtomDefaultNoExist()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodAtomDefault());
+        $this->assertTrue($this->conf->get('feed.show_atom'));
+    }
+    /**
+     * Test updateMethodAtomDefault with show_atom set to true.
+     * => nothing to do
+     */
+    public function testUpdateMethodAtomDefaultAlreadyTrue()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->conf->set('feed.show_atom', true);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodAtomDefault());
+        $this->assertTrue($this->conf->get('feed.show_atom'));
+    }
+
+    /**
+     * Test updateMethodDownloadSizeAndTimeoutConf, it should be set if none is already defined.
+     */
+    public function testUpdateMethodDownloadSizeAndTimeoutConf()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
+        $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
+        $this->assertEquals(30, $this->conf->get('general.download_timeout'));
+
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
+        $this->assertEquals(30, $this->conf->get('general.download_timeout'));
+    }
+
+    /**
+     * Test updateMethodDownloadSizeAndTimeoutConf, it shouldn't be set if it is already defined.
+     */
+    public function testUpdateMethodDownloadSizeAndTimeoutConfIgnore()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->conf->set('general.download_max_size', 38);
+        $this->conf->set('general.download_timeout', 70);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
+        $this->assertEquals(38, $this->conf->get('general.download_max_size'));
+        $this->assertEquals(70, $this->conf->get('general.download_timeout'));
+    }
+
+    /**
+     * Test updateMethodDownloadSizeAndTimeoutConf, only the maz size should be set here.
+     */
+    public function testUpdateMethodDownloadSizeAndTimeoutConfOnlySize()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->conf->set('general.download_max_size', 38);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
+        $this->assertEquals(38, $this->conf->get('general.download_max_size'));
+        $this->assertEquals(30, $this->conf->get('general.download_timeout'));
+    }
+
+    /**
+     * Test updateMethodDownloadSizeAndTimeoutConf, only the time out should be set here.
+     */
+    public function testUpdateMethodDownloadSizeAndTimeoutConfOnlyTimeout()
+    {
+        $sandboxConf = 'sandbox/config';
+        copy(self::$configFile . '.json.php', $sandboxConf . '.json.php');
+        $this->conf = new ConfigManager($sandboxConf);
+        $this->conf->set('general.download_timeout', 3);
+        $updater = new Updater([], [], $this->conf, true);
+        $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf());
+        $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
+        $this->assertEquals(3, $this->conf->get('general.download_timeout'));
+    }
+
+    /**
+<<<<<<< HEAD
+     * Test updateMethodWebThumbnailer with thumbnails enabled.
+     */
+    public function testUpdateMethodWebThumbnailerEnabled()
+    {
+        $this->conf->remove('thumbnails');
+        $this->conf->set('thumbnail.enable_thumbnails', true);
+        $updater = new Updater([], [], $this->conf, true, $_SESSION);
+        $this->assertTrue($updater->updateMethodWebThumbnailer());
+        $this->assertFalse($this->conf->exists('thumbnail'));
+        $this->assertEquals(\Shaarli\Thumbnailer::MODE_ALL, $this->conf->get('thumbnails.mode'));
+        $this->assertEquals(125, $this->conf->get('thumbnails.width'));
+        $this->assertEquals(90, $this->conf->get('thumbnails.height'));
+        $this->assertContains('You have enabled or changed thumbnails', $_SESSION['warnings'][0]);
+    }
+
+    /**
+     * Test updateMethodWebThumbnailer with thumbnails disabled.
+     */
+    public function testUpdateMethodWebThumbnailerDisabled()
+    {
+        $this->conf->remove('thumbnails');
+        $this->conf->set('thumbnail.enable_thumbnails', false);
+        $updater = new Updater([], [], $this->conf, true, $_SESSION);
+        $this->assertTrue($updater->updateMethodWebThumbnailer());
+        $this->assertFalse($this->conf->exists('thumbnail'));
+        $this->assertEquals(Thumbnailer::MODE_NONE, $this->conf->get('thumbnails.mode'));
+        $this->assertEquals(125, $this->conf->get('thumbnails.width'));
+        $this->assertEquals(90, $this->conf->get('thumbnails.height'));
+        $this->assertTrue(empty($_SESSION['warnings']));
+    }
+
+    /**
+     * Test updateMethodWebThumbnailer with thumbnails disabled.
+     */
+    public function testUpdateMethodWebThumbnailerNothingToDo()
+    {
+        $updater = new Updater([], [], $this->conf, true, $_SESSION);
+        $this->assertTrue($updater->updateMethodWebThumbnailer());
+        $this->assertFalse($this->conf->exists('thumbnail'));
+        $this->assertEquals(Thumbnailer::MODE_COMMON, $this->conf->get('thumbnails.mode'));
+        $this->assertEquals(90, $this->conf->get('thumbnails.width'));
+        $this->assertEquals(53, $this->conf->get('thumbnails.height'));
+        $this->assertTrue(empty($_SESSION['warnings']));
+    }
+
+    /**
+     * Test updateMethodSetSticky().
+     */
+    public function testUpdateStickyValid()
+    {
+        $blank = [
+            'id' => 1,
+            'url' => 'z',
+            'title' => '',
+            'description' => '',
+            'tags' => '',
+            'created' => new DateTime(),
+        ];
+        $links = [
+            1 => ['id' => 1] + $blank,
+            2 => ['id' => 2] + $blank,
+        ];
+        $refDB = new \ReferenceLinkDB();
+        $refDB->setLinks($links);
+        $refDB->write(self::$testDatastore);
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+
+        $updater = new Updater(array(), $linkDB, $this->conf, true);
+        $this->assertTrue($updater->updateMethodSetSticky());
+
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+        foreach ($linkDB as $link) {
+            $this->assertFalse($link['sticky']);
+        }
+    }
+
+    /**
+     * Test updateMethodSetSticky().
+     */
+    public function testUpdateStickyNothingToDo()
+    {
+        $blank = [
+            'id' => 1,
+            'url' => 'z',
+            'title' => '',
+            'description' => '',
+            'tags' => '',
+            'created' => new DateTime(),
+        ];
+        $links = [
+            1 => ['id' => 1, 'sticky' => true] + $blank,
+            2 => ['id' => 2] + $blank,
+        ];
+        $refDB = new \ReferenceLinkDB();
+        $refDB->setLinks($links);
+        $refDB->write(self::$testDatastore);
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+
+        $updater = new Updater(array(), $linkDB, $this->conf, true);
+        $this->assertTrue($updater->updateMethodSetSticky());
+
+        $linkDB = new LinkDB(self::$testDatastore, true, false);
+        $this->assertTrue($linkDB[1]['sticky']);
+    }
+}
diff --git a/tests/utils/config/configPhp.php b/tests/utils/config/configPhp.php
index 34b11fcd..7dc81e22 100644
--- a/tests/utils/config/configPhp.php
+++ b/tests/utils/config/configPhp.php
@@ -8,7 +8,7 @@ $GLOBALS['titleLink'] = 'titleLink';
 $GLOBALS['redirector'] = 'lala';
 $GLOBALS['disablesessionprotection'] = false;
 $GLOBALS['privateLinkByDefault'] = false;
-$GLOBALS['config']['DATADIR'] = 'tests/Updater';
+$GLOBALS['config']['DATADIR'] = 'tests/updater';
 $GLOBALS['config']['PAGECACHE'] = 'sandbox/pagecache';
 $GLOBALS['config']['DATASTORE'] = 'data/datastore.php';
 $GLOBALS['plugins']['WALLABAG_VERSION'] = '1';
-- 
cgit v1.2.3


From 9778a1551ce708b9f421a181806412a05410f1fb Mon Sep 17 00:00:00 2001
From: VirtualTam 
Date: Mon, 3 Dec 2018 23:58:59 +0100
Subject: namespacing: \Shaarli\ApplicationUtils

Signed-off-by: VirtualTam 
---
 application/ApplicationUtils.php     | 75 +++++++++++++++++++-----------------
 application/render/PageBuilder.php   |  2 +-
 application/updater/Updater.php      |  2 +-
 index.php                            |  4 +-
 tests/ApplicationUtilsTest.php       | 27 ++-----------
 tests/utils/FakeApplicationUtils.php | 19 +++++++++
 6 files changed, 67 insertions(+), 62 deletions(-)
 create mode 100644 tests/utils/FakeApplicationUtils.php

diff --git a/application/ApplicationUtils.php b/application/ApplicationUtils.php
index a3b2dcb1..7fe3cb32 100644
--- a/application/ApplicationUtils.php
+++ b/application/ApplicationUtils.php
@@ -1,4 +1,9 @@
 get('resource.theme'),
-        ) as $path) {
-            if (! is_readable(realpath($path))) {
-                $errors[] = '"'.$path.'" '. t('directory is not readable');
+                     'application',
+                     'inc',
+                     'plugins',
+                     $rainTplDir,
+                     $rainTplDir . '/' . $conf->get('resource.theme'),
+                 ) as $path) {
+            if (!is_readable(realpath($path))) {
+                $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
         }
 
         // Check cache and data directories are readable and writable
         foreach (array(
-            $conf->get('resource.thumbnails_cache'),
-            $conf->get('resource.data_dir'),
-            $conf->get('resource.page_cache'),
-            $conf->get('resource.raintpl_tmp'),
-        ) as $path) {
-            if (! is_readable(realpath($path))) {
-                $errors[] = '"'.$path.'" '. t('directory is not readable');
+                     $conf->get('resource.thumbnails_cache'),
+                     $conf->get('resource.data_dir'),
+                     $conf->get('resource.page_cache'),
+                     $conf->get('resource.raintpl_tmp'),
+                 ) as $path) {
+            if (!is_readable(realpath($path))) {
+                $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
-            if (! is_writable(realpath($path))) {
-                $errors[] = '"'.$path.'" '. t('directory is not writable');
+            if (!is_writable(realpath($path))) {
+                $errors[] = '"' . $path . '" ' . t('directory is not writable');
             }
         }
 
         // Check configuration files are readable and writable
         foreach (array(
-            $conf->getConfigFileExt(),
-            $conf->get('resource.datastore'),
-            $conf->get('resource.ban_file'),
-            $conf->get('resource.log'),
-            $conf->get('resource.update_check'),
-        ) as $path) {
-            if (! is_file(realpath($path))) {
+                     $conf->getConfigFileExt(),
+                     $conf->get('resource.datastore'),
+                     $conf->get('resource.ban_file'),
+                     $conf->get('resource.log'),
+                     $conf->get('resource.update_check'),
+                 ) as $path) {
+            if (!is_file(realpath($path))) {
                 # the file may not exist yet
                 continue;
             }
 
-            if (! is_readable(realpath($path))) {
-                $errors[] = '"'.$path.'" '. t('file is not readable');
+            if (!is_readable(realpath($path))) {
+                $errors[] = '"' . $path . '" ' . t('file is not readable');
             }
-            if (! is_writable(realpath($path))) {
-                $errors[] = '"'.$path.'" '. t('file is not writable');
+            if (!is_writable(realpath($path))) {
+                $errors[] = '"' . $path . '" ' . t('file is not writable');
             }
         }
 
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index 9a0fe61a..1c5b9251 100644
--- a/application/render/PageBuilder.php
+++ b/application/render/PageBuilder.php
@@ -2,7 +2,7 @@
 
 namespace Shaarli\Render;
 
-use ApplicationUtils;
+use Shaarli\ApplicationUtils;
 use Exception;
 use Shaarli\Bookmark\LinkDB;
 use RainTPL;
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index 55251a30..89f0ff7f 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -2,7 +2,7 @@
 
 namespace Shaarli\Updater;
 
-use ApplicationUtils;
+use Shaarli\ApplicationUtils;
 use Exception;
 use RainTPL;
 use ReflectionClass;
diff --git a/index.php b/index.php
index ce0373e1..3a7dab25 100644
--- a/index.php
+++ b/index.php
@@ -56,7 +56,6 @@ require_once 'inc/rain.tpl.class.php';
 require_once __DIR__ . '/vendor/autoload.php';
 
 // Shaarli library
-require_once 'application/ApplicationUtils.php';
 require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/config/ConfigPlugin.php';
 require_once 'application/feed/Cache.php';
@@ -71,6 +70,7 @@ require_once 'application/Utils.php';
 require_once 'application/PluginManager.php';
 require_once 'application/Router.php';
 
+use \Shaarli\ApplicationUtils;
 use \Shaarli\Bookmark\Exception\LinkNotFoundException;
 use \Shaarli\Bookmark\LinkDB;
 use \Shaarli\Config\ConfigManager;
@@ -83,7 +83,7 @@ use \Shaarli\Render\ThemeUtils;
 use \Shaarli\Security\LoginManager;
 use \Shaarli\Security\SessionManager;
 use \Shaarli\Thumbnailer;
-use Shaarli\Updater\Updater;
+use \Shaarli\Updater\Updater;
 
 // Ensure the PHP version is supported
 try {
diff --git a/tests/ApplicationUtilsTest.php b/tests/ApplicationUtilsTest.php
index fe5f84ce..82f8804d 100644
--- a/tests/ApplicationUtilsTest.php
+++ b/tests/ApplicationUtilsTest.php
@@ -1,33 +1,14 @@
 
Date: Tue, 4 Dec 2018 00:02:17 +0100
Subject: namespacing: \Shaarli\Router

Signed-off-by: VirtualTam 
---
 application/Router.php                      | 43 +++++++++++++++--------------
 index.php                                   |  3 +-
 plugins/addlink_toolbar/addlink_toolbar.php |  2 ++
 plugins/demo_plugin/demo_plugin.php         |  1 +
 plugins/isso/isso.php                       |  1 +
 plugins/markdown/markdown.php               |  1 +
 plugins/playvideos/playvideos.php           |  2 ++
 plugins/pubsubhubbub/pubsubhubbub.php       |  1 +
 plugins/qrcode/qrcode.php                   |  2 ++
 tests/RouterTest.php                        |  9 ++----
 tests/plugins/PluginAddlinkTest.php         |  2 ++
 tests/plugins/PluginPlayvideosTest.php      |  2 ++
 tests/plugins/PluginPubsubhubbubTest.php    |  1 +
 tests/plugins/PluginQrcodeTest.php          |  2 ++
 14 files changed, 42 insertions(+), 30 deletions(-)

diff --git a/application/Router.php b/application/Router.php
index beb3165b..05877acd 100644
--- a/application/Router.php
+++ b/application/Router.php
@@ -1,4 +1,5 @@
 
Date: Tue, 4 Dec 2018 00:13:42 +0100
Subject: namespacing: \Shaarli\Netscape\NetscapeBookmarkUtils

Signed-off-by: VirtualTam 
---
 application/NetscapeBookmarkUtils.php              | 220 -------
 application/netscape/NetscapeBookmarkUtils.php     | 225 +++++++
 composer.json                                      |   1 +
 index.php                                          |   2 +-
 tests/NetscapeBookmarkUtils/BookmarkExportTest.php | 136 -----
 tests/NetscapeBookmarkUtils/BookmarkImportTest.php | 657 ---------------------
 tests/NetscapeBookmarkUtils/input/empty.htm        |   0
 .../input/internet_explorer_encoding.htm           |   9 -
 .../input/lowercase_doctype.htm                    |   8 -
 .../NetscapeBookmarkUtils/input/netscape_basic.htm |  11 -
 .../input/netscape_nested.htm                      |  31 -
 tests/NetscapeBookmarkUtils/input/no_doctype.htm   |   7 -
 tests/NetscapeBookmarkUtils/input/same_date.htm    |  11 -
 tests/netscape/BookmarkExportTest.php              | 137 +++++
 tests/netscape/BookmarkImportTest.php              | 657 +++++++++++++++++++++
 tests/netscape/input/empty.htm                     |   0
 .../netscape/input/internet_explorer_encoding.htm  |   9 +
 tests/netscape/input/lowercase_doctype.htm         |   8 +
 tests/netscape/input/netscape_basic.htm            |  11 +
 tests/netscape/input/netscape_nested.htm           |  31 +
 tests/netscape/input/no_doctype.htm                |   7 +
 tests/netscape/input/same_date.htm                 |  11 +
 22 files changed, 1098 insertions(+), 1091 deletions(-)
 delete mode 100644 application/NetscapeBookmarkUtils.php
 create mode 100644 application/netscape/NetscapeBookmarkUtils.php
 delete mode 100644 tests/NetscapeBookmarkUtils/BookmarkExportTest.php
 delete mode 100644 tests/NetscapeBookmarkUtils/BookmarkImportTest.php
 delete mode 100644 tests/NetscapeBookmarkUtils/input/empty.htm
 delete mode 100644 tests/NetscapeBookmarkUtils/input/internet_explorer_encoding.htm
 delete mode 100644 tests/NetscapeBookmarkUtils/input/lowercase_doctype.htm
 delete mode 100644 tests/NetscapeBookmarkUtils/input/netscape_basic.htm
 delete mode 100644 tests/NetscapeBookmarkUtils/input/netscape_nested.htm
 delete mode 100644 tests/NetscapeBookmarkUtils/input/no_doctype.htm
 delete mode 100644 tests/NetscapeBookmarkUtils/input/same_date.htm
 create mode 100644 tests/netscape/BookmarkExportTest.php
 create mode 100644 tests/netscape/BookmarkImportTest.php
 create mode 100644 tests/netscape/input/empty.htm
 create mode 100644 tests/netscape/input/internet_explorer_encoding.htm
 create mode 100644 tests/netscape/input/lowercase_doctype.htm
 create mode 100644 tests/netscape/input/netscape_basic.htm
 create mode 100644 tests/netscape/input/netscape_nested.htm
 create mode 100644 tests/netscape/input/no_doctype.htm
 create mode 100644 tests/netscape/input/same_date.htm

diff --git a/application/NetscapeBookmarkUtils.php b/application/NetscapeBookmarkUtils.php
deleted file mode 100644
index e0022fe1..00000000
--- a/application/NetscapeBookmarkUtils.php
+++ /dev/null
@@ -1,220 +0,0 @@
-getTimestamp();
-            $link['taglist'] = str_replace(' ', ',', $link['tags']);
-
-            if (startsWith($link['url'], '?') && $prependNoteUrl) {
-                $link['url'] = $indexUrl . $link['url'];
-            }
-
-            $bookmarkLinks[] = $link;
-        }
-
-        return $bookmarkLinks;
-    }
-
-    /**
-     * Generates an import status summary
-     *
-     * @param string $filename       name of the file to import
-     * @param int    $filesize       size of the file to import
-     * @param int    $importCount    how many links were imported
-     * @param int    $overwriteCount how many links were overwritten
-     * @param int    $skipCount      how many links were skipped
-     * @param int    $duration       how many seconds did the import take
-     *
-     * @return string Summary of the bookmark import status
-     */
-    private static function importStatus(
-        $filename,
-        $filesize,
-        $importCount = 0,
-        $overwriteCount = 0,
-        $skipCount = 0,
-        $duration = 0
-    ) {
-        $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
-        if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
-            $status .= t('has an unknown file format. Nothing was imported.');
-        } else {
-            $status .= vsprintf(
-                t(
-                    'was successfully processed in %d seconds: '
-                    .'%d links imported, %d links overwritten, %d links skipped.'
-                ),
-                [$duration, $importCount, $overwriteCount, $skipCount]
-            );
-        }
-        return $status;
-    }
-
-    /**
-     * Imports Web bookmarks from an uploaded Netscape bookmark dump
-     *
-     * @param array         $post      Server $_POST parameters
-     * @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, $history)
-    {
-        $start = time();
-        $filename = $files['filetoupload']['name'];
-        $filesize = $files['filetoupload']['size'];
-        $data = file_get_contents($files['filetoupload']['tmp_name']);
-
-        if (preg_match('//i', $data) === 0) {
-            return self::importStatus($filename, $filesize);
-        }
-
-        // Overwrite existing links?
-        $overwrite = ! empty($post['overwrite']);
-
-        // Add tags to all imported links?
-        if (empty($post['default_tags'])) {
-            $defaultTags = array();
-        } else {
-            $defaultTags = preg_split(
-                '/[\s,]+/',
-                escape($post['default_tags'])
-            );
-        }
-
-        // links are imported as public by default
-        $defaultPrivacy = 0;
-
-        $parser = new NetscapeBookmarkParser(
-            true,                           // nested tag support
-            $defaultTags,                   // additional user-specified tags
-            strval(1 - $defaultPrivacy),    // defaultPub = 1 - defaultPrivacy
-            $conf->get('resource.data_dir') // log path, will be overridden
-        );
-        $logger = new Logger(
-            $conf->get('resource.data_dir'),
-            ! $conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
-            [
-                'prefix' => 'import.',
-                'extension' => 'log',
-            ]
-        );
-        $parser->setLogger($logger);
-        $bookmarks = $parser->parseString($data);
-
-        $importCount = 0;
-        $overwriteCount = 0;
-        $skipCount = 0;
-
-        foreach ($bookmarks as $bkm) {
-            $private = $defaultPrivacy;
-            if (empty($post['privacy']) || $post['privacy'] == 'default') {
-                // use value from the imported file
-                $private = $bkm['pub'] == '1' ? 0 : 1;
-            } elseif ($post['privacy'] == 'private') {
-                // all imported links are private
-                $private = 1;
-            } elseif ($post['privacy'] == 'public') {
-                // all imported links are public
-                $private = 0;
-            }
-
-            $newLink = array(
-                'title' => $bkm['title'],
-                'url' => $bkm['uri'],
-                'description' => $bkm['note'],
-                'private' => $private,
-                'tags' => $bkm['tags']
-            );
-
-            $existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
-
-            if ($existingLink !== false) {
-                if ($overwrite === false) {
-                    // Do not overwrite an existing link
-                    $skipCount++;
-                    continue;
-                }
-
-                // Overwrite an existing link, keep its date
-                $newLink['id'] = $existingLink['id'];
-                $newLink['created'] = $existingLink['created'];
-                $newLink['updated'] = new DateTime();
-                $newLink['shorturl'] = $existingLink['shorturl'];
-                $linkDb[$existingLink['id']] = $newLink;
-                $importCount++;
-                $overwriteCount++;
-                continue;
-            }
-
-            // Add a new link - @ used for UNIX timestamps
-            $newLinkDate = new DateTime('@'.strval($bkm['time']));
-            $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
-            $newLink['created'] = $newLinkDate;
-            $newLink['id'] = $linkDb->getNextId();
-            $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
-            $linkDb[$newLink['id']] = $newLink;
-            $importCount++;
-        }
-
-        $linkDb->save($conf->get('resource.page_cache'));
-        $history->importLinks();
-
-        $duration = time() - $start;
-        return self::importStatus(
-            $filename,
-            $filesize,
-            $importCount,
-            $overwriteCount,
-            $skipCount,
-            $duration
-        );
-    }
-}
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
new file mode 100644
index 00000000..2bf928c2
--- /dev/null
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -0,0 +1,225 @@
+getTimestamp();
+            $link['taglist'] = str_replace(' ', ',', $link['tags']);
+
+            if (startsWith($link['url'], '?') && $prependNoteUrl) {
+                $link['url'] = $indexUrl . $link['url'];
+            }
+
+            $bookmarkLinks[] = $link;
+        }
+
+        return $bookmarkLinks;
+    }
+
+    /**
+     * Generates an import status summary
+     *
+     * @param string $filename       name of the file to import
+     * @param int    $filesize       size of the file to import
+     * @param int    $importCount    how many links were imported
+     * @param int    $overwriteCount how many links were overwritten
+     * @param int    $skipCount      how many links were skipped
+     * @param int    $duration       how many seconds did the import take
+     *
+     * @return string Summary of the bookmark import status
+     */
+    private static function importStatus(
+        $filename,
+        $filesize,
+        $importCount = 0,
+        $overwriteCount = 0,
+        $skipCount = 0,
+        $duration = 0
+    ) {
+        $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
+        if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
+            $status .= t('has an unknown file format. Nothing was imported.');
+        } else {
+            $status .= vsprintf(
+                t(
+                    'was successfully processed in %d seconds: '
+                    . '%d links imported, %d links overwritten, %d links skipped.'
+                ),
+                [$duration, $importCount, $overwriteCount, $skipCount]
+            );
+        }
+        return $status;
+    }
+
+    /**
+     * Imports Web bookmarks from an uploaded Netscape bookmark dump
+     *
+     * @param array         $post    Server $_POST parameters
+     * @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, $history)
+    {
+        $start = time();
+        $filename = $files['filetoupload']['name'];
+        $filesize = $files['filetoupload']['size'];
+        $data = file_get_contents($files['filetoupload']['tmp_name']);
+
+        if (preg_match('//i', $data) === 0) {
+            return self::importStatus($filename, $filesize);
+        }
+
+        // Overwrite existing links?
+        $overwrite = !empty($post['overwrite']);
+
+        // Add tags to all imported links?
+        if (empty($post['default_tags'])) {
+            $defaultTags = array();
+        } else {
+            $defaultTags = preg_split(
+                '/[\s,]+/',
+                escape($post['default_tags'])
+            );
+        }
+
+        // links are imported as public by default
+        $defaultPrivacy = 0;
+
+        $parser = new NetscapeBookmarkParser(
+            true,                           // nested tag support
+            $defaultTags,                   // additional user-specified tags
+            strval(1 - $defaultPrivacy),    // defaultPub = 1 - defaultPrivacy
+            $conf->get('resource.data_dir') // log path, will be overridden
+        );
+        $logger = new Logger(
+            $conf->get('resource.data_dir'),
+            !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
+            [
+                'prefix' => 'import.',
+                'extension' => 'log',
+            ]
+        );
+        $parser->setLogger($logger);
+        $bookmarks = $parser->parseString($data);
+
+        $importCount = 0;
+        $overwriteCount = 0;
+        $skipCount = 0;
+
+        foreach ($bookmarks as $bkm) {
+            $private = $defaultPrivacy;
+            if (empty($post['privacy']) || $post['privacy'] == 'default') {
+                // use value from the imported file
+                $private = $bkm['pub'] == '1' ? 0 : 1;
+            } elseif ($post['privacy'] == 'private') {
+                // all imported links are private
+                $private = 1;
+            } elseif ($post['privacy'] == 'public') {
+                // all imported links are public
+                $private = 0;
+            }
+
+            $newLink = array(
+                'title' => $bkm['title'],
+                'url' => $bkm['uri'],
+                'description' => $bkm['note'],
+                'private' => $private,
+                'tags' => $bkm['tags']
+            );
+
+            $existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
+
+            if ($existingLink !== false) {
+                if ($overwrite === false) {
+                    // Do not overwrite an existing link
+                    $skipCount++;
+                    continue;
+                }
+
+                // Overwrite an existing link, keep its date
+                $newLink['id'] = $existingLink['id'];
+                $newLink['created'] = $existingLink['created'];
+                $newLink['updated'] = new DateTime();
+                $newLink['shorturl'] = $existingLink['shorturl'];
+                $linkDb[$existingLink['id']] = $newLink;
+                $importCount++;
+                $overwriteCount++;
+                continue;
+            }
+
+            // Add a new link - @ used for UNIX timestamps
+            $newLinkDate = new DateTime('@' . strval($bkm['time']));
+            $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
+            $newLink['created'] = $newLinkDate;
+            $newLink['id'] = $linkDb->getNextId();
+            $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
+            $linkDb[$newLink['id']] = $newLink;
+            $importCount++;
+        }
+
+        $linkDb->save($conf->get('resource.page_cache'));
+        $history->importLinks();
+
+        $duration = time() - $start;
+        return self::importStatus(
+            $filename,
+            $filesize,
+            $importCount,
+            $overwriteCount,
+            $skipCount,
+            $duration
+        );
+    }
+}
diff --git a/composer.json b/composer.json
index af763472..c1f47317 100644
--- a/composer.json
+++ b/composer.json
@@ -45,6 +45,7 @@
             "Shaarli\\Exceptions\\": "application/exceptions",
             "Shaarli\\Feed\\": "application/feed",
             "Shaarli\\Http\\": "application/http",
+            "Shaarli\\Netscape\\": "application/netscape",
             "Shaarli\\Render\\": "application/render",
             "Shaarli\\Security\\": "application/security",
             "Shaarli\\Updater\\": "application/updater",
diff --git a/index.php b/index.php
index 1dec569c..bff8e8ff 100644
--- a/index.php
+++ b/index.php
@@ -63,7 +63,6 @@ require_once 'application/http/HttpUtils.php';
 require_once 'application/http/UrlUtils.php';
 require_once 'application/updater/UpdaterUtils.php';
 require_once 'application/FileUtils.php';
-require_once 'application/NetscapeBookmarkUtils.php';
 require_once 'application/TimeZone.php';
 require_once 'application/Utils.php';
 require_once 'application/PluginManager.php';
@@ -76,6 +75,7 @@ use \Shaarli\Feed\CachedPage;
 use \Shaarli\Feed\FeedBuilder;
 use \Shaarli\History;
 use \Shaarli\Languages;
+use \Shaarli\Netscape\NetscapeBookmarkUtils;
 use \Shaarli\Render\PageBuilder;
 use \Shaarli\Render\ThemeUtils;
 use \Shaarli\Router;
diff --git a/tests/NetscapeBookmarkUtils/BookmarkExportTest.php b/tests/NetscapeBookmarkUtils/BookmarkExportTest.php
deleted file mode 100644
index adf854c5..00000000
--- a/tests/NetscapeBookmarkUtils/BookmarkExportTest.php
+++ /dev/null
@@ -1,136 +0,0 @@
-write(self::$testDatastore);
-        self::$linkDb = new LinkDB(self::$testDatastore, true, false);
-    }
-
-    /**
-     * Attempt to export an invalid link selection
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid export selection/
-     */
-    public function testFilterAndFormatInvalid()
-    {
-        NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'derp', false, '');
-    }
-
-    /**
-     * Prepare all links for export
-     */
-    public function testFilterAndFormatAll()
-    {
-        $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'all', false, '');
-        $this->assertEquals(self::$refDb->countLinks(), sizeof($links));
-        foreach ($links as $link) {
-            $date = $link['created'];
-            $this->assertEquals(
-                $date->getTimestamp(),
-                $link['timestamp']
-            );
-            $this->assertEquals(
-                str_replace(' ', ',', $link['tags']),
-                $link['taglist']
-            );
-        }
-    }
-
-    /**
-     * Prepare private links for export
-     */
-    public function testFilterAndFormatPrivate()
-    {
-        $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'private', false, '');
-        $this->assertEquals(self::$refDb->countPrivateLinks(), sizeof($links));
-        foreach ($links as $link) {
-            $date = $link['created'];
-            $this->assertEquals(
-                $date->getTimestamp(),
-                $link['timestamp']
-            );
-            $this->assertEquals(
-                str_replace(' ', ',', $link['tags']),
-                $link['taglist']
-            );
-        }
-    }
-
-    /**
-     * Prepare public links for export
-     */
-    public function testFilterAndFormatPublic()
-    {
-        $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'public', false, '');
-        $this->assertEquals(self::$refDb->countPublicLinks(), sizeof($links));
-        foreach ($links as $link) {
-            $date = $link['created'];
-            $this->assertEquals(
-                $date->getTimestamp(),
-                $link['timestamp']
-            );
-            $this->assertEquals(
-                str_replace(' ', ',', $link['tags']),
-                $link['taglist']
-            );
-        }
-    }
-
-    /**
-     * Do not prepend notes with the Shaarli index's URL
-     */
-    public function testFilterAndFormatDoNotPrependNoteUrl()
-    {
-        $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'public', false, '');
-        $this->assertEquals(
-            '?WDWyig',
-            $links[2]['url']
-        );
-    }
-
-    /**
-     * Prepend notes with the Shaarli index's URL
-     */
-    public function testFilterAndFormatPrependNoteUrl()
-    {
-        $indexUrl = 'http://localhost:7469/shaarli/';
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$linkDb,
-            'public',
-            true,
-            $indexUrl
-        );
-        $this->assertEquals(
-            $indexUrl . '?WDWyig',
-            $links[2]['url']
-        );
-    }
-}
diff --git a/tests/NetscapeBookmarkUtils/BookmarkImportTest.php b/tests/NetscapeBookmarkUtils/BookmarkImportTest.php
deleted file mode 100644
index 98c989bc..00000000
--- a/tests/NetscapeBookmarkUtils/BookmarkImportTest.php
+++ /dev/null
@@ -1,657 +0,0 @@
- array(
-            'name'     => $filename,
-            'tmp_name' => __DIR__ . '/input/' . $filename,
-            'size'     => filesize(__DIR__ . '/input/' . $filename)
-        )
-    );
-}
-
-
-/**
- * Netscape bookmark import
- */
-class BookmarkImportTest extends PHPUnit_Framework_TestCase
-{
-    /**
-     * @var string datastore to test write operations
-     */
-    protected static $testDatastore = 'sandbox/datastore.php';
-
-    /**
-     * @var string History file path
-     */
-    protected static $historyFilePath = 'sandbox/history.php';
-
-    /**
-     * @var LinkDB private LinkDB instance
-     */
-    protected $linkDb = null;
-
-    /**
-     * @var string Dummy page cache
-     */
-    protected $pagecache = 'tests';
-
-    /**
-     * @var ConfigManager instance.
-     */
-    protected $conf;
-
-    /**
-     * @var History instance.
-     */
-    protected $history;
-
-    /**
-     * @var string Save the current timezone.
-     */
-    protected static $defaultTimeZone;
-
-    public static function setUpBeforeClass()
-    {
-        self::$defaultTimeZone = date_default_timezone_get();
-        // Timezone without DST for test consistency
-        date_default_timezone_set('Africa/Nairobi');
-    }
-
-    /**
-     * Resets test data before each test
-     */
-    protected function setUp()
-    {
-        if (file_exists(self::$testDatastore)) {
-            unlink(self::$testDatastore);
-        }
-        // start with an empty datastore
-        file_put_contents(self::$testDatastore, '');
-        $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()
-    {
-        date_default_timezone_set(self::$defaultTimeZone);
-    }
-
-    /**
-     * Attempt to import bookmarks from an empty file
-     */
-    public function testImportEmptyData()
-    {
-        $files = file2array('empty.htm');
-        $this->assertEquals(
-            'File empty.htm (0 bytes) has an unknown file format.'
-            .' Nothing was imported.',
-            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
-        );
-        $this->assertEquals(0, count($this->linkDb));
-    }
-
-    /**
-     * Attempt to import bookmarks from a file with no Doctype
-     */
-    public function testImportNoDoctype()
-    {
-        $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, $this->history)
-        );
-        $this->assertEquals(0, count($this->linkDb));
-    }
-
-    /**
-     * Attempt to import bookmarks from a file with a lowercase Doctype
-     */
-    public function testImportLowecaseDoctype()
-    {
-        $files = file2array('lowercase_doctype.htm');
-        $this->assertStringMatchesFormat(
-            'File lowercase_doctype.htm (386 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import(null, $files, $this->linkDb, $this->conf, $this->history)
-        );
-        $this->assertEquals(2, count($this->linkDb));
-    }
-
-
-    /**
-     * Ensure IE dumps are supported
-     */
-    public function testImportInternetExplorerEncoding()
-    {
-        $files = file2array('internet_explorer_encoding.htm');
-        $this->assertStringMatchesFormat(
-            'File internet_explorer_encoding.htm (356 bytes) was successfully processed in %d seconds:'
-            .' 1 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
-        );
-        $this->assertEquals(1, count($this->linkDb));
-        $this->assertEquals(0, count_private($this->linkDb));
-
-        $this->assertEquals(
-            array(
-                'id' => 0,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160618_203944'),
-                'title' => 'Hg Init a Mercurial tutorial by Joel Spolsky',
-                'url' => 'http://hginit.com/',
-                'description' => '',
-                'private' => 0,
-                'tags' => '',
-                'shorturl' => 'La37cg',
-            ),
-            $this->linkDb->getLinkFromUrl('http://hginit.com/')
-        );
-    }
-
-    /**
-     * Import bookmarks nested in a folder hierarchy
-     */
-    public function testImportNested()
-    {
-        $files = file2array('netscape_nested.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_nested.htm (1337 bytes) was successfully processed in %d seconds:'
-            .' 8 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
-        );
-        $this->assertEquals(8, count($this->linkDb));
-        $this->assertEquals(2, count_private($this->linkDb));
-
-        $this->assertEquals(
-            array(
-                'id' => 0,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235541'),
-                'title' => 'Nested 1',
-                'url' => 'http://nest.ed/1',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'tag1 tag2',
-                'shorturl' => 'KyDNKA',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/1')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 1,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235542'),
-                'title' => 'Nested 1-1',
-                'url' => 'http://nest.ed/1-1',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'folder1 tag1 tag2',
-                'shorturl' => 'T2LnXg',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/1-1')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 2,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235547'),
-                'title' => 'Nested 1-2',
-                'url' => 'http://nest.ed/1-2',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'folder1 tag3 tag4',
-                'shorturl' => '46SZxA',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/1-2')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 3,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160202_202222'),
-                'title' => 'Nested 2-1',
-                'url' => 'http://nest.ed/2-1',
-                'description' => 'First link of the second section',
-                'private' => 1,
-                'tags' => 'folder2',
-                'shorturl' => '4UHOSw',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/2-1')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 4,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160119_230227'),
-                'title' => 'Nested 2-2',
-                'url' => 'http://nest.ed/2-2',
-                'description' => 'Second link of the second section',
-                'private' => 1,
-                'tags' => 'folder2',
-                'shorturl' => 'yfzwbw',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/2-2')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 5,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160202_202222'),
-                'title' => 'Nested 3-1',
-                'url' => 'http://nest.ed/3-1',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'folder3 folder3-1 tag3',
-                'shorturl' => 'UwxIUQ',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/3-1')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 6,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160119_230227'),
-                'title' => 'Nested 3-2',
-                'url' => 'http://nest.ed/3-2',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'folder3 folder3-1',
-                'shorturl' => 'p8dyZg',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/3-2')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 7,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160229_111541'),
-                'title' => 'Nested 2',
-                'url' => 'http://nest.ed/2',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'tag4',
-                'shorturl' => 'Gt3Uug',
-            ),
-            $this->linkDb->getLinkFromUrl('http://nest.ed/2')
-        );
-    }
-
-    /**
-     * Import bookmarks with the default privacy setting (reuse from file)
-     *
-     * The $_POST array is not set.
-     */
-    public function testImportDefaultPrivacyNoPost()
-    {
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
-        );
-
-        $this->assertEquals(2, count($this->linkDb));
-        $this->assertEquals(1, count_private($this->linkDb));
-
-        $this->assertEquals(
-            array(
-                'id' => 0,
-                // Old link - UTC+4 (note that TZ in the import file is ignored).
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20001010_135536'),
-                'title' => 'Secret stuff',
-                'url' => 'https://private.tld',
-                'description' => "Super-secret stuff you're not supposed to know about",
-                'private' => 1,
-                'tags' => 'private secret',
-                'shorturl' => 'EokDtA',
-            ),
-            $this->linkDb->getLinkFromUrl('https://private.tld')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 1,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235548'),
-                'title' => 'Public stuff',
-                'url' => 'http://public.tld',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'public hello world',
-                'shorturl' => 'Er9ddA',
-            ),
-            $this->linkDb->getLinkFromUrl('http://public.tld')
-        );
-    }
-
-    /**
-     * Import bookmarks with the default privacy setting (reuse from file)
-     */
-    public function testImportKeepPrivacy()
-    {
-        $post = array('privacy' => 'default');
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            array(
-                'id' => 0,
-                // Note that TZ in the import file is ignored.
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20001010_135536'),
-                'title' => 'Secret stuff',
-                'url' => 'https://private.tld',
-                'description' => "Super-secret stuff you're not supposed to know about",
-                'private' => 1,
-                'tags' => 'private secret',
-                'shorturl' => 'EokDtA',
-            ),
-            $this->linkDb->getLinkFromUrl('https://private.tld')
-        );
-        $this->assertEquals(
-            array(
-                'id' => 1,
-                'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235548'),
-                'title' => 'Public stuff',
-                'url' => 'http://public.tld',
-                'description' => '',
-                'private' => 0,
-                'tags' => 'public hello world',
-                'shorturl' => 'Er9ddA',
-            ),
-            $this->linkDb->getLinkFromUrl('http://public.tld')
-        );
-    }
-
-    /**
-     * Import links as public
-     */
-    public function testImportAsPublic()
-    {
-        $post = array('privacy' => 'public');
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            0,
-            $this->linkDb[0]['private']
-        );
-        $this->assertEquals(
-            0,
-            $this->linkDb[1]['private']
-        );
-    }
-
-    /**
-     * Import links as private
-     */
-    public function testImportAsPrivate()
-    {
-        $post = array('privacy' => 'private');
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            1,
-            $this->linkDb['0']['private']
-        );
-        $this->assertEquals(
-            1,
-            $this->linkDb['1']['private']
-        );
-    }
-
-    /**
-     * Overwrite private links so they become public
-     */
-    public function testOverwriteAsPublic()
-    {
-        $files = file2array('netscape_basic.htm');
-
-        // import links as private
-        $post = array('privacy' => 'private');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            1,
-            $this->linkDb[0]['private']
-        );
-        $this->assertEquals(
-            1,
-            $this->linkDb[1]['private']
-        );
-        // re-import as public, enable overwriting
-        $post = array(
-            'privacy' => 'public',
-            'overwrite' => 'true'
-        );
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 2 links overwritten, 0 links skipped.',
-            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(
-            0,
-            $this->linkDb[0]['private']
-        );
-        $this->assertEquals(
-            0,
-            $this->linkDb[1]['private']
-        );
-    }
-
-    /**
-     * Overwrite public links so they become private
-     */
-    public function testOverwriteAsPrivate()
-    {
-        $files = file2array('netscape_basic.htm');
-
-        // import links as public
-        $post = array('privacy' => 'public');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            0,
-            $this->linkDb['0']['private']
-        );
-        $this->assertEquals(
-            0,
-            $this->linkDb['1']['private']
-        );
-
-        // re-import as private, enable overwriting
-        $post = array(
-            'privacy' => 'private',
-            'overwrite' => 'true'
-        );
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 2 links overwritten, 0 links skipped.',
-            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(
-            1,
-            $this->linkDb['0']['private']
-        );
-        $this->assertEquals(
-            1,
-            $this->linkDb['1']['private']
-        );
-    }
-
-    /**
-     * Attept to import the same links twice without enabling overwriting
-     */
-    public function testSkipOverwrite()
-    {
-        $post = array('privacy' => 'public');
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
-        );
-        $this->assertEquals(2, count($this->linkDb));
-        $this->assertEquals(0, count_private($this->linkDb));
-
-        // re-import as private, DO NOT enable overwriting
-        $post = array('privacy' => 'private');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 0 links imported, 0 links overwritten, 2 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
-        );
-        $this->assertEquals(2, count($this->linkDb));
-        $this->assertEquals(0, count_private($this->linkDb));
-    }
-
-    /**
-     * Add user-specified tags to all imported bookmarks
-     */
-    public function testSetDefaultTags()
-    {
-        $post = array(
-            'privacy' => 'public',
-            'default_tags' => 'tag1,tag2 tag3'
-        );
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            'tag1 tag2 tag3 private secret',
-            $this->linkDb['0']['tags']
-        );
-        $this->assertEquals(
-            'tag1 tag2 tag3 public hello world',
-            $this->linkDb['1']['tags']
-        );
-    }
-
-    /**
-     * The user-specified tags contain characters to be escaped
-     */
-    public function testSanitizeDefaultTags()
-    {
-        $post = array(
-            'privacy' => 'public',
-            'default_tags' => 'tag1&,tag2 "tag3"'
-        );
-        $files = file2array('netscape_basic.htm');
-        $this->assertStringMatchesFormat(
-            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
-            .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            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(
-            'tag1& tag2 "tag3" private secret',
-            $this->linkDb['0']['tags']
-        );
-        $this->assertEquals(
-            'tag1& tag2 "tag3" public hello world',
-            $this->linkDb['1']['tags']
-        );
-    }
-
-    /**
-     * Ensure each imported bookmark has a unique id
-     *
-     * See https://github.com/shaarli/Shaarli/issues/351
-     */
-    public function testImportSameDate()
-    {
-        $files = file2array('same_date.htm');
-        $this->assertStringMatchesFormat(
-            'File same_date.htm (453 bytes) was successfully processed in %d seconds:'
-            .' 3 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf, $this->history)
-        );
-        $this->assertEquals(3, count($this->linkDb));
-        $this->assertEquals(0, count_private($this->linkDb));
-        $this->assertEquals(
-            0,
-            $this->linkDb[0]['id']
-        );
-        $this->assertEquals(
-            1,
-            $this->linkDb[1]['id']
-        );
-        $this->assertEquals(
-            2,
-            $this->linkDb[2]['id']
-        );
-    }
-
-    public function testImportCreateUpdateHistory()
-    {
-        $post = [
-            'privacy' => 'public',
-            'overwrite' => 'true',
-        ];
-        $files = file2array('netscape_basic.htm');
-        NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history);
-        $history = $this->history->getHistory();
-        $this->assertEquals(1, count($history));
-        $this->assertEquals(History::IMPORT, $history[0]['event']);
-        $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']);
-
-        // re-import as private, enable overwriting
-        NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history);
-        $history = $this->history->getHistory();
-        $this->assertEquals(2, count($history));
-        $this->assertEquals(History::IMPORT, $history[0]['event']);
-        $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']);
-        $this->assertEquals(History::IMPORT, $history[1]['event']);
-        $this->assertTrue(new DateTime('-5 seconds') < $history[1]['datetime']);
-    }
-}
diff --git a/tests/NetscapeBookmarkUtils/input/empty.htm b/tests/NetscapeBookmarkUtils/input/empty.htm
deleted file mode 100644
index e69de29b..00000000
diff --git a/tests/NetscapeBookmarkUtils/input/internet_explorer_encoding.htm b/tests/NetscapeBookmarkUtils/input/internet_explorer_encoding.htm
deleted file mode 100644
index 18703cf6..00000000
--- a/tests/NetscapeBookmarkUtils/input/internet_explorer_encoding.htm
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-Bookmarks
-

Bookmarks

-

-

Hg Init a Mercurial tutorial by Joel Spolsky -

diff --git a/tests/NetscapeBookmarkUtils/input/lowercase_doctype.htm b/tests/NetscapeBookmarkUtils/input/lowercase_doctype.htm deleted file mode 100644 index 8911ad19..00000000 --- a/tests/NetscapeBookmarkUtils/input/lowercase_doctype.htm +++ /dev/null @@ -1,8 +0,0 @@ - -Bookmarks -

Bookmarks

-

-

Secret stuff -
Super-secret stuff you're not supposed to know about -
Public stuff -

diff --git a/tests/NetscapeBookmarkUtils/input/netscape_basic.htm b/tests/NetscapeBookmarkUtils/input/netscape_basic.htm deleted file mode 100644 index affe0cf8..00000000 --- a/tests/NetscapeBookmarkUtils/input/netscape_basic.htm +++ /dev/null @@ -1,11 +0,0 @@ - - -Bookmarks -

Bookmarks

-

-

Secret stuff -
Super-secret stuff you're not supposed to know about -
Public stuff -

diff --git a/tests/NetscapeBookmarkUtils/input/netscape_nested.htm b/tests/NetscapeBookmarkUtils/input/netscape_nested.htm deleted file mode 100644 index b486fe18..00000000 --- a/tests/NetscapeBookmarkUtils/input/netscape_nested.htm +++ /dev/null @@ -1,31 +0,0 @@ - - -Bookmarks -

Bookmarks

-

-

Nested 1 -

Folder1

-

-

Nested 1-1 -
Nested 1-2 -

-

Folder2

-
This second folder contains wonderful links! -

-

Nested 2-1 -
First link of the second section -
Nested 2-2 -
Second link of the second section -

-

Folder3

-

-

Folder3-1

-

-

Nested 3-1 -
Nested 3-2 -

-

-

Nested 2 -

diff --git a/tests/NetscapeBookmarkUtils/input/no_doctype.htm b/tests/NetscapeBookmarkUtils/input/no_doctype.htm deleted file mode 100644 index 766d398b..00000000 --- a/tests/NetscapeBookmarkUtils/input/no_doctype.htm +++ /dev/null @@ -1,7 +0,0 @@ -Bookmarks -

Bookmarks

-

-

Secret stuff -
Super-secret stuff you're not supposed to know about -
Public stuff -

diff --git a/tests/NetscapeBookmarkUtils/input/same_date.htm b/tests/NetscapeBookmarkUtils/input/same_date.htm deleted file mode 100644 index 9d58a582..00000000 --- a/tests/NetscapeBookmarkUtils/input/same_date.htm +++ /dev/null @@ -1,11 +0,0 @@ - - -Bookmarks -

Bookmarks

-

-

Today's first link -
Today's second link -
Today's third link -

diff --git a/tests/netscape/BookmarkExportTest.php b/tests/netscape/BookmarkExportTest.php new file mode 100644 index 00000000..6de9876d --- /dev/null +++ b/tests/netscape/BookmarkExportTest.php @@ -0,0 +1,137 @@ +write(self::$testDatastore); + self::$linkDb = new LinkDB(self::$testDatastore, true, false); + } + + /** + * Attempt to export an invalid link selection + * @expectedException Exception + * @expectedExceptionMessageRegExp /Invalid export selection/ + */ + public function testFilterAndFormatInvalid() + { + NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'derp', false, ''); + } + + /** + * Prepare all links for export + */ + public function testFilterAndFormatAll() + { + $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'all', false, ''); + $this->assertEquals(self::$refDb->countLinks(), sizeof($links)); + foreach ($links as $link) { + $date = $link['created']; + $this->assertEquals( + $date->getTimestamp(), + $link['timestamp'] + ); + $this->assertEquals( + str_replace(' ', ',', $link['tags']), + $link['taglist'] + ); + } + } + + /** + * Prepare private links for export + */ + public function testFilterAndFormatPrivate() + { + $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'private', false, ''); + $this->assertEquals(self::$refDb->countPrivateLinks(), sizeof($links)); + foreach ($links as $link) { + $date = $link['created']; + $this->assertEquals( + $date->getTimestamp(), + $link['timestamp'] + ); + $this->assertEquals( + str_replace(' ', ',', $link['tags']), + $link['taglist'] + ); + } + } + + /** + * Prepare public links for export + */ + public function testFilterAndFormatPublic() + { + $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'public', false, ''); + $this->assertEquals(self::$refDb->countPublicLinks(), sizeof($links)); + foreach ($links as $link) { + $date = $link['created']; + $this->assertEquals( + $date->getTimestamp(), + $link['timestamp'] + ); + $this->assertEquals( + str_replace(' ', ',', $link['tags']), + $link['taglist'] + ); + } + } + + /** + * Do not prepend notes with the Shaarli index's URL + */ + public function testFilterAndFormatDoNotPrependNoteUrl() + { + $links = NetscapeBookmarkUtils::filterAndFormat(self::$linkDb, 'public', false, ''); + $this->assertEquals( + '?WDWyig', + $links[2]['url'] + ); + } + + /** + * Prepend notes with the Shaarli index's URL + */ + public function testFilterAndFormatPrependNoteUrl() + { + $indexUrl = 'http://localhost:7469/shaarli/'; + $links = NetscapeBookmarkUtils::filterAndFormat( + self::$linkDb, + 'public', + true, + $indexUrl + ); + $this->assertEquals( + $indexUrl . '?WDWyig', + $links[2]['url'] + ); + } +} diff --git a/tests/netscape/BookmarkImportTest.php b/tests/netscape/BookmarkImportTest.php new file mode 100644 index 00000000..ccafc161 --- /dev/null +++ b/tests/netscape/BookmarkImportTest.php @@ -0,0 +1,657 @@ + array( + 'name' => $filename, + 'tmp_name' => __DIR__ . '/input/' . $filename, + 'size' => filesize(__DIR__ . '/input/' . $filename) + ) + ); +} + + +/** + * Netscape bookmark import + */ +class BookmarkImportTest extends \PHPUnit\Framework\TestCase +{ + /** + * @var string datastore to test write operations + */ + protected static $testDatastore = 'sandbox/datastore.php'; + + /** + * @var string History file path + */ + protected static $historyFilePath = 'sandbox/history.php'; + + /** + * @var LinkDB private LinkDB instance + */ + protected $linkDb = null; + + /** + * @var string Dummy page cache + */ + protected $pagecache = 'tests'; + + /** + * @var ConfigManager instance. + */ + protected $conf; + + /** + * @var History instance. + */ + protected $history; + + /** + * @var string Save the current timezone. + */ + protected static $defaultTimeZone; + + public static function setUpBeforeClass() + { + self::$defaultTimeZone = date_default_timezone_get(); + // Timezone without DST for test consistency + date_default_timezone_set('Africa/Nairobi'); + } + + /** + * Resets test data before each test + */ + protected function setUp() + { + if (file_exists(self::$testDatastore)) { + unlink(self::$testDatastore); + } + // start with an empty datastore + file_put_contents(self::$testDatastore, ''); + $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() + { + date_default_timezone_set(self::$defaultTimeZone); + } + + /** + * Attempt to import bookmarks from an empty file + */ + public function testImportEmptyData() + { + $files = file2array('empty.htm'); + $this->assertEquals( + 'File empty.htm (0 bytes) has an unknown file format.' + .' Nothing was imported.', + NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history) + ); + $this->assertEquals(0, count($this->linkDb)); + } + + /** + * Attempt to import bookmarks from a file with no Doctype + */ + public function testImportNoDoctype() + { + $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, $this->history) + ); + $this->assertEquals(0, count($this->linkDb)); + } + + /** + * Attempt to import bookmarks from a file with a lowercase Doctype + */ + public function testImportLowecaseDoctype() + { + $files = file2array('lowercase_doctype.htm'); + $this->assertStringMatchesFormat( + 'File lowercase_doctype.htm (386 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + NetscapeBookmarkUtils::import(null, $files, $this->linkDb, $this->conf, $this->history) + ); + $this->assertEquals(2, count($this->linkDb)); + } + + + /** + * Ensure IE dumps are supported + */ + public function testImportInternetExplorerEncoding() + { + $files = file2array('internet_explorer_encoding.htm'); + $this->assertStringMatchesFormat( + 'File internet_explorer_encoding.htm (356 bytes) was successfully processed in %d seconds:' + .' 1 links imported, 0 links overwritten, 0 links skipped.', + NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history) + ); + $this->assertEquals(1, count($this->linkDb)); + $this->assertEquals(0, count_private($this->linkDb)); + + $this->assertEquals( + array( + 'id' => 0, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160618_203944'), + 'title' => 'Hg Init a Mercurial tutorial by Joel Spolsky', + 'url' => 'http://hginit.com/', + 'description' => '', + 'private' => 0, + 'tags' => '', + 'shorturl' => 'La37cg', + ), + $this->linkDb->getLinkFromUrl('http://hginit.com/') + ); + } + + /** + * Import bookmarks nested in a folder hierarchy + */ + public function testImportNested() + { + $files = file2array('netscape_nested.htm'); + $this->assertStringMatchesFormat( + 'File netscape_nested.htm (1337 bytes) was successfully processed in %d seconds:' + .' 8 links imported, 0 links overwritten, 0 links skipped.', + NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history) + ); + $this->assertEquals(8, count($this->linkDb)); + $this->assertEquals(2, count_private($this->linkDb)); + + $this->assertEquals( + array( + 'id' => 0, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235541'), + 'title' => 'Nested 1', + 'url' => 'http://nest.ed/1', + 'description' => '', + 'private' => 0, + 'tags' => 'tag1 tag2', + 'shorturl' => 'KyDNKA', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/1') + ); + $this->assertEquals( + array( + 'id' => 1, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235542'), + 'title' => 'Nested 1-1', + 'url' => 'http://nest.ed/1-1', + 'description' => '', + 'private' => 0, + 'tags' => 'folder1 tag1 tag2', + 'shorturl' => 'T2LnXg', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/1-1') + ); + $this->assertEquals( + array( + 'id' => 2, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235547'), + 'title' => 'Nested 1-2', + 'url' => 'http://nest.ed/1-2', + 'description' => '', + 'private' => 0, + 'tags' => 'folder1 tag3 tag4', + 'shorturl' => '46SZxA', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/1-2') + ); + $this->assertEquals( + array( + 'id' => 3, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160202_202222'), + 'title' => 'Nested 2-1', + 'url' => 'http://nest.ed/2-1', + 'description' => 'First link of the second section', + 'private' => 1, + 'tags' => 'folder2', + 'shorturl' => '4UHOSw', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/2-1') + ); + $this->assertEquals( + array( + 'id' => 4, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160119_230227'), + 'title' => 'Nested 2-2', + 'url' => 'http://nest.ed/2-2', + 'description' => 'Second link of the second section', + 'private' => 1, + 'tags' => 'folder2', + 'shorturl' => 'yfzwbw', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/2-2') + ); + $this->assertEquals( + array( + 'id' => 5, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160202_202222'), + 'title' => 'Nested 3-1', + 'url' => 'http://nest.ed/3-1', + 'description' => '', + 'private' => 0, + 'tags' => 'folder3 folder3-1 tag3', + 'shorturl' => 'UwxIUQ', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/3-1') + ); + $this->assertEquals( + array( + 'id' => 6, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160119_230227'), + 'title' => 'Nested 3-2', + 'url' => 'http://nest.ed/3-2', + 'description' => '', + 'private' => 0, + 'tags' => 'folder3 folder3-1', + 'shorturl' => 'p8dyZg', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/3-2') + ); + $this->assertEquals( + array( + 'id' => 7, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160229_111541'), + 'title' => 'Nested 2', + 'url' => 'http://nest.ed/2', + 'description' => '', + 'private' => 0, + 'tags' => 'tag4', + 'shorturl' => 'Gt3Uug', + ), + $this->linkDb->getLinkFromUrl('http://nest.ed/2') + ); + } + + /** + * Import bookmarks with the default privacy setting (reuse from file) + * + * The $_POST array is not set. + */ + public function testImportDefaultPrivacyNoPost() + { + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history) + ); + + $this->assertEquals(2, count($this->linkDb)); + $this->assertEquals(1, count_private($this->linkDb)); + + $this->assertEquals( + array( + 'id' => 0, + // Old link - UTC+4 (note that TZ in the import file is ignored). + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20001010_135536'), + 'title' => 'Secret stuff', + 'url' => 'https://private.tld', + 'description' => "Super-secret stuff you're not supposed to know about", + 'private' => 1, + 'tags' => 'private secret', + 'shorturl' => 'EokDtA', + ), + $this->linkDb->getLinkFromUrl('https://private.tld') + ); + $this->assertEquals( + array( + 'id' => 1, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235548'), + 'title' => 'Public stuff', + 'url' => 'http://public.tld', + 'description' => '', + 'private' => 0, + 'tags' => 'public hello world', + 'shorturl' => 'Er9ddA', + ), + $this->linkDb->getLinkFromUrl('http://public.tld') + ); + } + + /** + * Import bookmarks with the default privacy setting (reuse from file) + */ + public function testImportKeepPrivacy() + { + $post = array('privacy' => 'default'); + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + array( + 'id' => 0, + // Note that TZ in the import file is ignored. + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20001010_135536'), + 'title' => 'Secret stuff', + 'url' => 'https://private.tld', + 'description' => "Super-secret stuff you're not supposed to know about", + 'private' => 1, + 'tags' => 'private secret', + 'shorturl' => 'EokDtA', + ), + $this->linkDb->getLinkFromUrl('https://private.tld') + ); + $this->assertEquals( + array( + 'id' => 1, + 'created' => DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160225_235548'), + 'title' => 'Public stuff', + 'url' => 'http://public.tld', + 'description' => '', + 'private' => 0, + 'tags' => 'public hello world', + 'shorturl' => 'Er9ddA', + ), + $this->linkDb->getLinkFromUrl('http://public.tld') + ); + } + + /** + * Import links as public + */ + public function testImportAsPublic() + { + $post = array('privacy' => 'public'); + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + 0, + $this->linkDb[0]['private'] + ); + $this->assertEquals( + 0, + $this->linkDb[1]['private'] + ); + } + + /** + * Import links as private + */ + public function testImportAsPrivate() + { + $post = array('privacy' => 'private'); + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + 1, + $this->linkDb['0']['private'] + ); + $this->assertEquals( + 1, + $this->linkDb['1']['private'] + ); + } + + /** + * Overwrite private links so they become public + */ + public function testOverwriteAsPublic() + { + $files = file2array('netscape_basic.htm'); + + // import links as private + $post = array('privacy' => 'private'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + 1, + $this->linkDb[0]['private'] + ); + $this->assertEquals( + 1, + $this->linkDb[1]['private'] + ); + // re-import as public, enable overwriting + $post = array( + 'privacy' => 'public', + 'overwrite' => 'true' + ); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 2 links overwritten, 0 links skipped.', + 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( + 0, + $this->linkDb[0]['private'] + ); + $this->assertEquals( + 0, + $this->linkDb[1]['private'] + ); + } + + /** + * Overwrite public links so they become private + */ + public function testOverwriteAsPrivate() + { + $files = file2array('netscape_basic.htm'); + + // import links as public + $post = array('privacy' => 'public'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + 0, + $this->linkDb['0']['private'] + ); + $this->assertEquals( + 0, + $this->linkDb['1']['private'] + ); + + // re-import as private, enable overwriting + $post = array( + 'privacy' => 'private', + 'overwrite' => 'true' + ); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 2 links overwritten, 0 links skipped.', + 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( + 1, + $this->linkDb['0']['private'] + ); + $this->assertEquals( + 1, + $this->linkDb['1']['private'] + ); + } + + /** + * Attept to import the same links twice without enabling overwriting + */ + public function testSkipOverwrite() + { + $post = array('privacy' => 'public'); + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) + ); + $this->assertEquals(2, count($this->linkDb)); + $this->assertEquals(0, count_private($this->linkDb)); + + // re-import as private, DO NOT enable overwriting + $post = array('privacy' => 'private'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 0 links imported, 0 links overwritten, 2 links skipped.', + NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history) + ); + $this->assertEquals(2, count($this->linkDb)); + $this->assertEquals(0, count_private($this->linkDb)); + } + + /** + * Add user-specified tags to all imported bookmarks + */ + public function testSetDefaultTags() + { + $post = array( + 'privacy' => 'public', + 'default_tags' => 'tag1,tag2 tag3' + ); + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + 'tag1 tag2 tag3 private secret', + $this->linkDb['0']['tags'] + ); + $this->assertEquals( + 'tag1 tag2 tag3 public hello world', + $this->linkDb['1']['tags'] + ); + } + + /** + * The user-specified tags contain characters to be escaped + */ + public function testSanitizeDefaultTags() + { + $post = array( + 'privacy' => 'public', + 'default_tags' => 'tag1&,tag2 "tag3"' + ); + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 links imported, 0 links overwritten, 0 links skipped.', + 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( + 'tag1& tag2 "tag3" private secret', + $this->linkDb['0']['tags'] + ); + $this->assertEquals( + 'tag1& tag2 "tag3" public hello world', + $this->linkDb['1']['tags'] + ); + } + + /** + * Ensure each imported bookmark has a unique id + * + * See https://github.com/shaarli/Shaarli/issues/351 + */ + public function testImportSameDate() + { + $files = file2array('same_date.htm'); + $this->assertStringMatchesFormat( + 'File same_date.htm (453 bytes) was successfully processed in %d seconds:' + .' 3 links imported, 0 links overwritten, 0 links skipped.', + NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf, $this->history) + ); + $this->assertEquals(3, count($this->linkDb)); + $this->assertEquals(0, count_private($this->linkDb)); + $this->assertEquals( + 0, + $this->linkDb[0]['id'] + ); + $this->assertEquals( + 1, + $this->linkDb[1]['id'] + ); + $this->assertEquals( + 2, + $this->linkDb[2]['id'] + ); + } + + public function testImportCreateUpdateHistory() + { + $post = [ + 'privacy' => 'public', + 'overwrite' => 'true', + ]; + $files = file2array('netscape_basic.htm'); + NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history); + $history = $this->history->getHistory(); + $this->assertEquals(1, count($history)); + $this->assertEquals(History::IMPORT, $history[0]['event']); + $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']); + + // re-import as private, enable overwriting + NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history); + $history = $this->history->getHistory(); + $this->assertEquals(2, count($history)); + $this->assertEquals(History::IMPORT, $history[0]['event']); + $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']); + $this->assertEquals(History::IMPORT, $history[1]['event']); + $this->assertTrue(new DateTime('-5 seconds') < $history[1]['datetime']); + } +} diff --git a/tests/netscape/input/empty.htm b/tests/netscape/input/empty.htm new file mode 100644 index 00000000..e69de29b diff --git a/tests/netscape/input/internet_explorer_encoding.htm b/tests/netscape/input/internet_explorer_encoding.htm new file mode 100644 index 00000000..18703cf6 --- /dev/null +++ b/tests/netscape/input/internet_explorer_encoding.htm @@ -0,0 +1,9 @@ + + +Bookmarks +

Bookmarks

+

+

Hg Init a Mercurial tutorial by Joel Spolsky +

diff --git a/tests/netscape/input/lowercase_doctype.htm b/tests/netscape/input/lowercase_doctype.htm new file mode 100644 index 00000000..8911ad19 --- /dev/null +++ b/tests/netscape/input/lowercase_doctype.htm @@ -0,0 +1,8 @@ + +Bookmarks +

Bookmarks

+

+

Secret stuff +
Super-secret stuff you're not supposed to know about +
Public stuff +

diff --git a/tests/netscape/input/netscape_basic.htm b/tests/netscape/input/netscape_basic.htm new file mode 100644 index 00000000..affe0cf8 --- /dev/null +++ b/tests/netscape/input/netscape_basic.htm @@ -0,0 +1,11 @@ + + +Bookmarks +

Bookmarks

+

+

Secret stuff +
Super-secret stuff you're not supposed to know about +
Public stuff +

diff --git a/tests/netscape/input/netscape_nested.htm b/tests/netscape/input/netscape_nested.htm new file mode 100644 index 00000000..b486fe18 --- /dev/null +++ b/tests/netscape/input/netscape_nested.htm @@ -0,0 +1,31 @@ + + +Bookmarks +

Bookmarks

+

+

Nested 1 +

Folder1

+

+

Nested 1-1 +
Nested 1-2 +

+

Folder2

+
This second folder contains wonderful links! +

+

Nested 2-1 +
First link of the second section +
Nested 2-2 +
Second link of the second section +

+

Folder3

+

+

Folder3-1

+

+

Nested 3-1 +
Nested 3-2 +

+

+

Nested 2 +

diff --git a/tests/netscape/input/no_doctype.htm b/tests/netscape/input/no_doctype.htm new file mode 100644 index 00000000..766d398b --- /dev/null +++ b/tests/netscape/input/no_doctype.htm @@ -0,0 +1,7 @@ +Bookmarks +

Bookmarks

+

+

Secret stuff +
Super-secret stuff you're not supposed to know about +
Public stuff +

diff --git a/tests/netscape/input/same_date.htm b/tests/netscape/input/same_date.htm new file mode 100644 index 00000000..9d58a582 --- /dev/null +++ b/tests/netscape/input/same_date.htm @@ -0,0 +1,11 @@ + + +Bookmarks +

Bookmarks

+

+

Today's first link +
Today's second link +
Today's third link +

-- cgit v1.2.3 From e1850388348d4bfdf463a5aa341bc470da79cf32 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Tue, 4 Dec 2018 00:26:50 +0100 Subject: namespacing: \Shaarli\Plugin\PluginManager Signed-off-by: VirtualTam --- application/PluginManager.php | 244 --------------------- application/plugin/PluginManager.php | 233 ++++++++++++++++++++ .../exception/PluginFileNotFoundException.php | 23 ++ composer.json | 2 + index.php | 2 +- plugins/archiveorg/archiveorg.php | 2 + plugins/demo_plugin/demo_plugin.php | 1 + plugins/isso/isso.php | 1 + plugins/markdown/markdown.php | 1 + plugins/piwik/piwik.php | 2 + plugins/playvideos/playvideos.php | 1 + plugins/pubsubhubbub/pubsubhubbub.php | 1 + plugins/qrcode/qrcode.php | 1 + plugins/wallabag/wallabag.php | 1 + tests/PluginManagerTest.php | 10 +- tests/plugins/PluginAddlinkTest.php | 9 +- tests/plugins/PluginArchiveorgTest.php | 2 + tests/plugins/PluginIssoTest.php | 1 + tests/plugins/PluginMarkdownTest.php | 1 + tests/plugins/PluginPlayvideosTest.php | 1 + tests/plugins/PluginPubsubhubbubTest.php | 1 + tests/plugins/PluginQrcodeTest.php | 1 + tests/plugins/PluginWallabagTest.php | 3 +- 23 files changed, 284 insertions(+), 260 deletions(-) delete mode 100644 application/PluginManager.php create mode 100644 application/plugin/PluginManager.php create mode 100644 application/plugin/exception/PluginFileNotFoundException.php diff --git a/application/PluginManager.php b/application/PluginManager.php deleted file mode 100644 index 1ed4db4b..00000000 --- a/application/PluginManager.php +++ /dev/null @@ -1,244 +0,0 @@ -conf = $conf; - $this->errors = array(); - } - - /** - * Load plugins listed in $authorizedPlugins. - * - * @param array $authorizedPlugins Names of plugin authorized to be loaded. - * - * @return void - */ - public function load($authorizedPlugins) - { - $this->authorizedPlugins = $authorizedPlugins; - - $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR); - $dirnames = array_map('basename', $dirs); - foreach ($this->authorizedPlugins as $plugin) { - $index = array_search($plugin, $dirnames); - - // plugin authorized, but its folder isn't listed - if ($index === false) { - continue; - } - - try { - $this->loadPlugin($dirs[$index], $plugin); - } catch (PluginFileNotFoundException $e) { - error_log($e->getMessage()); - } - } - } - - /** - * Execute all plugins registered hook. - * - * @param string $hook name of the hook to trigger. - * @param array $data list of data to manipulate passed by reference. - * @param array $params additional parameters such as page target. - * - * @return void - */ - public function executeHooks($hook, &$data, $params = array()) - { - if (!empty($params['target'])) { - $data['_PAGE_'] = $params['target']; - } - - if (isset($params['loggedin'])) { - $data['_LOGGEDIN_'] = $params['loggedin']; - } - - foreach ($this->loadedPlugins as $plugin) { - $hookFunction = $this->buildHookName($hook, $plugin); - - if (function_exists($hookFunction)) { - $data = call_user_func($hookFunction, $data, $this->conf); - } - } - } - - /** - * Load a single plugin from its files. - * Call the init function if it exists, and collect errors. - * Add them in $loadedPlugins if successful. - * - * @param string $dir plugin's directory. - * @param string $pluginName plugin's name. - * - * @return void - * @throws PluginFileNotFoundException - plugin files not found. - */ - private function loadPlugin($dir, $pluginName) - { - if (!is_dir($dir)) { - throw new PluginFileNotFoundException($pluginName); - } - - $pluginFilePath = $dir . '/' . $pluginName . '.php'; - if (!is_file($pluginFilePath)) { - throw new PluginFileNotFoundException($pluginName); - } - - $conf = $this->conf; - include_once $pluginFilePath; - - $initFunction = $pluginName . '_init'; - if (function_exists($initFunction)) { - $errors = call_user_func($initFunction, $this->conf); - if (!empty($errors)) { - $this->errors = array_merge($this->errors, $errors); - } - } - - $this->loadedPlugins[] = $pluginName; - } - - /** - * Construct normalize hook name for a specific plugin. - * - * Format: - * hook__ - * - * @param string $hook hook name. - * @param string $pluginName plugin name. - * - * @return string - plugin's hook name. - */ - public function buildHookName($hook, $pluginName) - { - return 'hook_' . $pluginName . '_' . $hook; - } - - /** - * Retrieve plugins metadata from *.meta (INI) files into an array. - * Metadata contains: - * - plugin description [description] - * - parameters split with ';' [parameters] - * - * Respects plugins order from settings. - * - * @return array plugins metadata. - */ - public function getPluginsMeta() - { - $metaData = array(); - $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); - - // Browse all plugin directories. - foreach ($dirs as $pluginDir) { - $plugin = basename($pluginDir); - $metaFile = $pluginDir . $plugin . '.' . self::$META_EXT; - if (!is_file($metaFile) || !is_readable($metaFile)) { - continue; - } - - $metaData[$plugin] = parse_ini_file($metaFile); - $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins); - - if (isset($metaData[$plugin]['description'])) { - $metaData[$plugin]['description'] = t($metaData[$plugin]['description']); - } - // Read parameters and format them into an array. - if (isset($metaData[$plugin]['parameters'])) { - $params = explode(';', $metaData[$plugin]['parameters']); - } else { - $params = array(); - } - $metaData[$plugin]['parameters'] = array(); - foreach ($params as $param) { - if (empty($param)) { - continue; - } - - $metaData[$plugin]['parameters'][$param]['value'] = ''; - // Optional parameter description in parameter.PARAM_NAME= - if (isset($metaData[$plugin]['parameter.'. $param])) { - $metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.'. $param]); - } - } - } - - return $metaData; - } - - /** - * Return the list of encountered errors. - * - * @return array List of errors (empty array if none exists). - */ - public function getErrors() - { - return $this->errors; - } -} - -/** - * Class PluginFileNotFoundException - * - * Raise when plugin files can't be found. - */ -class PluginFileNotFoundException extends Exception -{ - /** - * Construct exception with plugin name. - * Generate message. - * - * @param string $pluginName name of the plugin not found - */ - public function __construct($pluginName) - { - $this->message = sprintf(t('Plugin "%s" files not found.'), $pluginName); - } -} diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php new file mode 100644 index 00000000..f7b24a8e --- /dev/null +++ b/application/plugin/PluginManager.php @@ -0,0 +1,233 @@ +conf = $conf; + $this->errors = array(); + } + + /** + * Load plugins listed in $authorizedPlugins. + * + * @param array $authorizedPlugins Names of plugin authorized to be loaded. + * + * @return void + */ + public function load($authorizedPlugins) + { + $this->authorizedPlugins = $authorizedPlugins; + + $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR); + $dirnames = array_map('basename', $dirs); + foreach ($this->authorizedPlugins as $plugin) { + $index = array_search($plugin, $dirnames); + + // plugin authorized, but its folder isn't listed + if ($index === false) { + continue; + } + + try { + $this->loadPlugin($dirs[$index], $plugin); + } catch (PluginFileNotFoundException $e) { + error_log($e->getMessage()); + } + } + } + + /** + * Execute all plugins registered hook. + * + * @param string $hook name of the hook to trigger. + * @param array $data list of data to manipulate passed by reference. + * @param array $params additional parameters such as page target. + * + * @return void + */ + public function executeHooks($hook, &$data, $params = array()) + { + if (!empty($params['target'])) { + $data['_PAGE_'] = $params['target']; + } + + if (isset($params['loggedin'])) { + $data['_LOGGEDIN_'] = $params['loggedin']; + } + + foreach ($this->loadedPlugins as $plugin) { + $hookFunction = $this->buildHookName($hook, $plugin); + + if (function_exists($hookFunction)) { + $data = call_user_func($hookFunction, $data, $this->conf); + } + } + } + + /** + * Load a single plugin from its files. + * Call the init function if it exists, and collect errors. + * Add them in $loadedPlugins if successful. + * + * @param string $dir plugin's directory. + * @param string $pluginName plugin's name. + * + * @return void + * @throws \Shaarli\Plugin\Exception\PluginFileNotFoundException - plugin files not found. + */ + private function loadPlugin($dir, $pluginName) + { + if (!is_dir($dir)) { + throw new PluginFileNotFoundException($pluginName); + } + + $pluginFilePath = $dir . '/' . $pluginName . '.php'; + if (!is_file($pluginFilePath)) { + throw new PluginFileNotFoundException($pluginName); + } + + $conf = $this->conf; + include_once $pluginFilePath; + + $initFunction = $pluginName . '_init'; + if (function_exists($initFunction)) { + $errors = call_user_func($initFunction, $this->conf); + if (!empty($errors)) { + $this->errors = array_merge($this->errors, $errors); + } + } + + $this->loadedPlugins[] = $pluginName; + } + + /** + * Construct normalize hook name for a specific plugin. + * + * Format: + * hook__ + * + * @param string $hook hook name. + * @param string $pluginName plugin name. + * + * @return string - plugin's hook name. + */ + public function buildHookName($hook, $pluginName) + { + return 'hook_' . $pluginName . '_' . $hook; + } + + /** + * Retrieve plugins metadata from *.meta (INI) files into an array. + * Metadata contains: + * - plugin description [description] + * - parameters split with ';' [parameters] + * + * Respects plugins order from settings. + * + * @return array plugins metadata. + */ + public function getPluginsMeta() + { + $metaData = array(); + $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); + + // Browse all plugin directories. + foreach ($dirs as $pluginDir) { + $plugin = basename($pluginDir); + $metaFile = $pluginDir . $plugin . '.' . self::$META_EXT; + if (!is_file($metaFile) || !is_readable($metaFile)) { + continue; + } + + $metaData[$plugin] = parse_ini_file($metaFile); + $metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins); + + if (isset($metaData[$plugin]['description'])) { + $metaData[$plugin]['description'] = t($metaData[$plugin]['description']); + } + // Read parameters and format them into an array. + if (isset($metaData[$plugin]['parameters'])) { + $params = explode(';', $metaData[$plugin]['parameters']); + } else { + $params = array(); + } + $metaData[$plugin]['parameters'] = array(); + foreach ($params as $param) { + if (empty($param)) { + continue; + } + + $metaData[$plugin]['parameters'][$param]['value'] = ''; + // Optional parameter description in parameter.PARAM_NAME= + if (isset($metaData[$plugin]['parameter.' . $param])) { + $metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.' . $param]); + } + } + } + + return $metaData; + } + + /** + * Return the list of encountered errors. + * + * @return array List of errors (empty array if none exists). + */ + public function getErrors() + { + return $this->errors; + } +} diff --git a/application/plugin/exception/PluginFileNotFoundException.php b/application/plugin/exception/PluginFileNotFoundException.php new file mode 100644 index 00000000..e5386f02 --- /dev/null +++ b/application/plugin/exception/PluginFileNotFoundException.php @@ -0,0 +1,23 @@ +message = sprintf(t('Plugin "%s" files not found.'), $pluginName); + } +} diff --git a/composer.json b/composer.json index c1f47317..a2df466a 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,8 @@ "Shaarli\\Feed\\": "application/feed", "Shaarli\\Http\\": "application/http", "Shaarli\\Netscape\\": "application/netscape", + "Shaarli\\Plugin\\": "application/plugin", + "Shaarli\\Plugin\\Exception\\": "application/plugin/exception", "Shaarli\\Render\\": "application/render", "Shaarli\\Security\\": "application/security", "Shaarli\\Updater\\": "application/updater", diff --git a/index.php b/index.php index bff8e8ff..633ab89e 100644 --- a/index.php +++ b/index.php @@ -65,7 +65,6 @@ require_once 'application/updater/UpdaterUtils.php'; require_once 'application/FileUtils.php'; require_once 'application/TimeZone.php'; require_once 'application/Utils.php'; -require_once 'application/PluginManager.php'; use \Shaarli\ApplicationUtils; use \Shaarli\Bookmark\Exception\LinkNotFoundException; @@ -76,6 +75,7 @@ use \Shaarli\Feed\FeedBuilder; use \Shaarli\History; use \Shaarli\Languages; use \Shaarli\Netscape\NetscapeBookmarkUtils; +use \Shaarli\Plugin\PluginManager; use \Shaarli\Render\PageBuilder; use \Shaarli\Render\ThemeUtils; use \Shaarli\Router; diff --git a/plugins/archiveorg/archiveorg.php b/plugins/archiveorg/archiveorg.php index 5dcea5a6..0ee1c73c 100644 --- a/plugins/archiveorg/archiveorg.php +++ b/plugins/archiveorg/archiveorg.php @@ -5,6 +5,8 @@ * Add an icon in the link list for archive.org. */ +use Shaarli\Plugin\PluginManager; + /** * Add archive.org icon to link_plugin when rendering linklist. * diff --git a/plugins/demo_plugin/demo_plugin.php b/plugins/demo_plugin/demo_plugin.php index 94ce38f8..95ea7fe2 100644 --- a/plugins/demo_plugin/demo_plugin.php +++ b/plugins/demo_plugin/demo_plugin.php @@ -15,6 +15,7 @@ */ use Shaarli\Config\ConfigManager; +use Shaarli\Plugin\PluginManager; use Shaarli\Router; /** diff --git a/plugins/isso/isso.php b/plugins/isso/isso.php index 9bdd5909..dab75dd5 100644 --- a/plugins/isso/isso.php +++ b/plugins/isso/isso.php @@ -5,6 +5,7 @@ */ use Shaarli\Config\ConfigManager; +use Shaarli\Plugin\PluginManager; use Shaarli\Router; /** diff --git a/plugins/markdown/markdown.php b/plugins/markdown/markdown.php index 9928a488..628970d6 100644 --- a/plugins/markdown/markdown.php +++ b/plugins/markdown/markdown.php @@ -7,6 +7,7 @@ */ use Shaarli\Config\ConfigManager; +use Shaarli\Plugin\PluginManager; use Shaarli\Router; /* diff --git a/plugins/piwik/piwik.php b/plugins/piwik/piwik.php index ca00c2be..17b1aecc 100644 --- a/plugins/piwik/piwik.php +++ b/plugins/piwik/piwik.php @@ -4,6 +4,8 @@ * Adds tracking code on each page. */ +use Shaarli\Plugin\PluginManager; + /** * Initialization function. * It will be called when the plugin is loaded. diff --git a/plugins/playvideos/playvideos.php b/plugins/playvideos/playvideos.php index bb5b9e98..0341ed59 100644 --- a/plugins/playvideos/playvideos.php +++ b/plugins/playvideos/playvideos.php @@ -6,6 +6,7 @@ * Note: this plugin adds jQuery. */ +use Shaarli\Plugin\PluginManager; use Shaarli\Router; /** diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php index a7bd34c1..2878c050 100644 --- a/plugins/pubsubhubbub/pubsubhubbub.php +++ b/plugins/pubsubhubbub/pubsubhubbub.php @@ -12,6 +12,7 @@ use pubsubhubbub\publisher\Publisher; use Shaarli\Config\ConfigManager; use Shaarli\Feed\FeedBuilder; +use Shaarli\Plugin\PluginManager; use Shaarli\Router; /** diff --git a/plugins/qrcode/qrcode.php b/plugins/qrcode/qrcode.php index 21908cee..34eef8be 100644 --- a/plugins/qrcode/qrcode.php +++ b/plugins/qrcode/qrcode.php @@ -5,6 +5,7 @@ * Display a QRCode icon in link list. */ +use Shaarli\Plugin\PluginManager; use Shaarli\Router; /** diff --git a/plugins/wallabag/wallabag.php b/plugins/wallabag/wallabag.php index a6476c71..5ba1611d 100644 --- a/plugins/wallabag/wallabag.php +++ b/plugins/wallabag/wallabag.php @@ -6,6 +6,7 @@ require_once 'WallabagInstance.php'; use Shaarli\Config\ConfigManager; +use Shaarli\Plugin\PluginManager; /** * Init function, return an error if the server is not set. diff --git a/tests/PluginManagerTest.php b/tests/PluginManagerTest.php index 01de959c..71761ac1 100644 --- a/tests/PluginManagerTest.php +++ b/tests/PluginManagerTest.php @@ -1,16 +1,12 @@ Date: Tue, 4 Dec 2018 23:17:23 +0100 Subject: namespacing: add plugin tests to \Shaarli\Plugin\[...] Signed-off-by: VirtualTam --- composer.json | 1 + plugins/wallabag/WallabagInstance.php | 1 + plugins/wallabag/wallabag.php | 5 ++--- tests/plugins/PluginAddlinkTest.php | 1 + tests/plugins/PluginArchiveorgTest.php | 3 ++- tests/plugins/PluginIssoTest.php | 4 +++- tests/plugins/PluginMarkdownTest.php | 4 +++- tests/plugins/PluginPlayvideosTest.php | 4 ++-- tests/plugins/PluginPubsubhubbubTest.php | 5 +++-- tests/plugins/PluginQrcodeTest.php | 5 +++-- tests/plugins/PluginWallabagTest.php | 6 ++---- tests/plugins/WallabagInstanceTest.php | 5 +++-- 12 files changed, 26 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index a2df466a..8a98cfb6 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "Shaarli\\Netscape\\": "application/netscape", "Shaarli\\Plugin\\": "application/plugin", "Shaarli\\Plugin\\Exception\\": "application/plugin/exception", + "Shaarli\\Plugin\\Wallabag\\": "plugins/wallabag", "Shaarli\\Render\\": "application/render", "Shaarli\\Security\\": "application/security", "Shaarli\\Updater\\": "application/updater", diff --git a/plugins/wallabag/WallabagInstance.php b/plugins/wallabag/WallabagInstance.php index eb8ab618..f4a0a92b 100644 --- a/plugins/wallabag/WallabagInstance.php +++ b/plugins/wallabag/WallabagInstance.php @@ -1,4 +1,5 @@ Date: Tue, 4 Dec 2018 23:49:29 +0100 Subject: composer: add and document optional PHP extensions Signed-off-by: VirtualTam --- composer.json | 7 ++++++- composer.lock | 66 ++++++++++++++++++++++++++++++----------------------------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/composer.json b/composer.json index 8a98cfb6..a52b5f78 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ }, "require": { "php": ">=5.6", + "ext-json": "*", "ext-zlib": "*", "shaarli/netscape-bookmark-parser": "^2.1", "erusev/parsedown": "^1.6", @@ -30,7 +31,11 @@ "squizlabs/php_codesniffer": "2.*" }, "suggest": { - "ext-curl": "*" + "ext-curl": "Allows fetching web pages and thumbnails in a more robust way", + "ext-gd": "Required for thumbnail generation", + "ext-gettext": "Enables faster translation system in gettext mode", + "ext-intl": "Provides localized text sorting", + "ext-mbstring": "Provides multibyte (Unicode) string support" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 5723cff4..53fb2175 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "45937c29ca4a55504b0d0a60a03c42c6", + "content-hash": "f8965821c946c2a1271c3f8c7e8c6eea", "packages": [ { "name": "arthurhoaro/web-thumbnailer", @@ -133,16 +133,16 @@ }, { "name": "gettext/gettext", - "version": "v4.6.1", + "version": "v4.6.2", "source": { "type": "git", "url": "https://github.com/oscarotero/Gettext.git", - "reference": "854ff5f5aaf92d2af7080ba8fc15718b27b5c89a" + "reference": "93176b272d61fb58a9767be71c50d19149cb1e48" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oscarotero/Gettext/zipball/854ff5f5aaf92d2af7080ba8fc15718b27b5c89a", - "reference": "854ff5f5aaf92d2af7080ba8fc15718b27b5c89a", + "url": "https://api.github.com/repos/oscarotero/Gettext/zipball/93176b272d61fb58a9767be71c50d19149cb1e48", + "reference": "93176b272d61fb58a9767be71c50d19149cb1e48", "shasum": "" }, "require": { @@ -191,7 +191,7 @@ "po", "translation" ], - "time": "2018-08-27T15:40:19+00:00" + "time": "2019-01-12T18:40:56+00:00" }, { "name": "gettext/languages", @@ -2101,16 +2101,16 @@ }, { "name": "symfony/console", - "version": "v3.4.19", + "version": "v3.4.21", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8f80fc39bbc3b7c47ee54ba7aa2653521ace94bb" + "reference": "a700b874d3692bc8342199adfb6d3b99f62cc61a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8f80fc39bbc3b7c47ee54ba7aa2653521ace94bb", - "reference": "8f80fc39bbc3b7c47ee54ba7aa2653521ace94bb", + "url": "https://api.github.com/repos/symfony/console/zipball/a700b874d3692bc8342199adfb6d3b99f62cc61a", + "reference": "a700b874d3692bc8342199adfb6d3b99f62cc61a", "shasum": "" }, "require": { @@ -2166,20 +2166,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2018-11-26T12:48:07+00:00" + "time": "2019-01-04T04:42:43+00:00" }, { "name": "symfony/debug", - "version": "v3.4.19", + "version": "v3.4.21", "source": { "type": "git", "url": "https://github.com/symfony/debug.git", - "reference": "2016b3eec2e49c127dd02d0ef44a35c53181560d" + "reference": "26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/2016b3eec2e49c127dd02d0ef44a35c53181560d", - "reference": "2016b3eec2e49c127dd02d0ef44a35c53181560d", + "url": "https://api.github.com/repos/symfony/debug/zipball/26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186", + "reference": "26d7f23b9bd0b93bee5583e4d6ca5cb1ab31b186", "shasum": "" }, "require": { @@ -2222,20 +2222,20 @@ ], "description": "Symfony Debug Component", "homepage": "https://symfony.com", - "time": "2018-11-11T19:48:54+00:00" + "time": "2019-01-01T13:45:19+00:00" }, { "name": "symfony/finder", - "version": "v3.4.19", + "version": "v3.4.21", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6cf2be5cbd0e87aa35c01f80ae0bf40b6798e442" + "reference": "3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6cf2be5cbd0e87aa35c01f80ae0bf40b6798e442", - "reference": "6cf2be5cbd0e87aa35c01f80ae0bf40b6798e442", + "url": "https://api.github.com/repos/symfony/finder/zipball/3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e", + "reference": "3f2a2ab6315dd7682d4c16dcae1e7b95c8b8555e", "shasum": "" }, "require": { @@ -2271,7 +2271,7 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2018-11-11T19:48:54+00:00" + "time": "2019-01-01T13:45:19+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2392,16 +2392,16 @@ }, { "name": "symfony/yaml", - "version": "v3.4.19", + "version": "v3.4.21", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "291e13d808bec481eab83f301f7bff3e699ef603" + "reference": "554a59a1ccbaac238a89b19c8e551a556fd0e2ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/291e13d808bec481eab83f301f7bff3e699ef603", - "reference": "291e13d808bec481eab83f301f7bff3e699ef603", + "url": "https://api.github.com/repos/symfony/yaml/zipball/554a59a1ccbaac238a89b19c8e551a556fd0e2ea", + "reference": "554a59a1ccbaac238a89b19c8e551a556fd0e2ea", "shasum": "" }, "require": { @@ -2447,7 +2447,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2018-11-11T19:48:54+00:00" + "time": "2019-01-01T13:45:19+00:00" }, { "name": "theseer/fdomdocument", @@ -2491,20 +2491,21 @@ }, { "name": "webmozart/assert", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "0df1908962e7a3071564e857d86874dad1ef204a" + "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a", - "reference": "0df1908962e7a3071564e857d86874dad1ef204a", + "url": "https://api.github.com/repos/webmozart/assert/zipball/83e253c8e0be5b0257b881e1827274667c5c17a9", + "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9", "shasum": "" }, "require": { - "php": "^5.3.3 || ^7.0" + "php": "^5.3.3 || ^7.0", + "symfony/polyfill-ctype": "^1.8" }, "require-dev": { "phpunit/phpunit": "^4.6", @@ -2537,7 +2538,7 @@ "check", "validate" ], - "time": "2018-01-29T19:49:41+00:00" + "time": "2018-12-25T11:19:39+00:00" } ], "aliases": [], @@ -2549,6 +2550,7 @@ "prefer-lowest": false, "platform": { "php": ">=5.6", + "ext-json": "*", "ext-zlib": "*" }, "platform-dev": [], -- cgit v1.2.3 From a43e7842e44068584302ec1d6349155b571d9c96 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sat, 12 Jan 2019 23:19:15 +0100 Subject: API: update test regexes to comply with PCRE2 Signed-off-by: VirtualTam --- tests/api/controllers/links/PostLinkTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api/controllers/links/PostLinkTest.php b/tests/api/controllers/links/PostLinkTest.php index ade07eeb..24e591db 100644 --- a/tests/api/controllers/links/PostLinkTest.php +++ b/tests/api/controllers/links/PostLinkTest.php @@ -121,7 +121,7 @@ class PostLinkTest extends TestCase $data = json_decode((string) $response->getBody(), true); $this->assertEquals(self::NB_FIELDS_LINK, count($data)); $this->assertEquals(43, $data['id']); - $this->assertRegExp('/[\w-_]{6}/', $data['shorturl']); + $this->assertRegExp('/[\w_-]{6}/', $data['shorturl']); $this->assertEquals('http://domain.tld/?' . $data['shorturl'], $data['url']); $this->assertEquals('?' . $data['shorturl'], $data['title']); $this->assertEquals('', $data['description']); @@ -166,7 +166,7 @@ class PostLinkTest extends TestCase $data = json_decode((string) $response->getBody(), true); $this->assertEquals(self::NB_FIELDS_LINK, count($data)); $this->assertEquals(43, $data['id']); - $this->assertRegExp('/[\w-_]{6}/', $data['shorturl']); + $this->assertRegExp('/[\w_-]{6}/', $data['shorturl']); $this->assertEquals('http://' . $link['url'], $data['url']); $this->assertEquals($link['title'], $data['title']); $this->assertEquals($link['description'], $data['description']); -- cgit v1.2.3 From dea72c711ff740b3b829d238fcf85648465143a0 Mon Sep 17 00:00:00 2001 From: VirtualTam Date: Sat, 12 Jan 2019 23:55:38 +0100 Subject: Optimize and cleanup imports Signed-off-by: VirtualTam --- application/Languages.php | 1 - application/Thumbnailer.php | 2 +- application/api/ApiMiddleware.php | 3 +- application/api/ApiUtils.php | 2 +- application/api/controllers/ApiController.php | 7 ++- application/api/controllers/History.php | 69 ---------------------- application/api/controllers/HistoryController.php | 69 ++++++++++++++++++++++ application/api/controllers/Tags.php | 1 - .../api/exceptions/ApiLinkNotFoundException.php | 2 - .../api/exceptions/ApiTagNotFoundException.php | 2 - application/bookmark/LinkDB.php | 1 - application/feed/FeedBuilder.php | 1 - application/netscape/NetscapeBookmarkUtils.php | 2 +- application/render/PageBuilder.php | 4 +- application/updater/Updater.php | 2 +- tests/LanguagesTest.php | 2 +- tests/TimeZoneTest.php | 2 +- tests/UtilsTest.php | 2 +- tests/api/ApiMiddlewareTest.php | 3 +- tests/api/ApiUtilsTest.php | 2 +- tests/api/controllers/history/HistoryTest.php | 28 ++++----- tests/api/controllers/info/InfoTest.php | 3 +- tests/api/controllers/links/DeleteLinkTest.php | 18 +++--- tests/api/controllers/links/GetLinkIdTest.php | 3 +- tests/api/controllers/links/GetLinksTest.php | 10 ++-- tests/api/controllers/links/PostLinkTest.php | 9 +-- tests/api/controllers/links/PutLinkTest.php | 11 ++-- tests/api/controllers/tags/DeleteTagTest.php | 22 +++---- tests/api/controllers/tags/GetTagNameTest.php | 6 +- tests/api/controllers/tags/GetTagsTest.php | 8 +-- tests/api/controllers/tags/PutTagTest.php | 17 +++--- tests/bookmark/LinkDBTest.php | 1 - tests/config/ConfigJsonTest.php | 2 +- tests/config/ConfigManagerTest.php | 2 +- tests/config/ConfigPhpTest.php | 2 +- tests/config/ConfigPluginTest.php | 2 +- tests/feed/FeedBuilderTest.php | 2 +- tests/languages/fr/LanguagesFrTest.php | 2 +- tests/plugins/WallabagInstanceTest.php | 2 - tests/render/ThemeUtilsTest.php | 2 +- tests/security/LoginManagerTest.php | 3 +- tests/security/SessionManagerTest.php | 4 +- 42 files changed, 166 insertions(+), 172 deletions(-) delete mode 100644 application/api/controllers/History.php create mode 100644 application/api/controllers/HistoryController.php diff --git a/application/Languages.php b/application/Languages.php index b9c5d0e8..5cda802e 100644 --- a/application/Languages.php +++ b/application/Languages.php @@ -3,7 +3,6 @@ namespace Shaarli; use Gettext\GettextTranslator; -use Gettext\Merge; use Gettext\Translations; use Gettext\Translator; use Gettext\TranslatorInterface; diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php index 37ed97a1..a23f98e9 100644 --- a/application/Thumbnailer.php +++ b/application/Thumbnailer.php @@ -3,9 +3,9 @@ namespace Shaarli; use Shaarli\Config\ConfigManager; +use WebThumbnailer\Application\ConfigManager as WTConfigManager; use WebThumbnailer\Exception\WebThumbnailerException; use WebThumbnailer\WebThumbnailer; -use WebThumbnailer\Application\ConfigManager as WTConfigManager; /** * Class Thumbnailer diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index a2101f29..5ffb8c6d 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -1,9 +1,8 @@ history->getHistory(); - - // Return history operations from the {offset}th, starting from {since}. - $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since')); - $offset = $request->getParam('offset'); - if (empty($offset)) { - $offset = 0; - } elseif (ctype_digit($offset)) { - $offset = (int) $offset; - } else { - throw new ApiBadParametersException('Invalid offset'); - } - - // limit parameter is either a number of links or 'all' for everything. - $limit = $request->getParam('limit'); - if (empty($limit)) { - $limit = count($history); - } elseif (ctype_digit($limit)) { - $limit = (int) $limit; - } else { - throw new ApiBadParametersException('Invalid limit'); - } - - $out = []; - $i = 0; - foreach ($history as $entry) { - if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) { - break; - } - if (++$i > $offset) { - $out[$i] = $entry; - $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM); - } - } - $out = array_values($out); - - return $response->withJson($out, 200, $this->jsonStyle); - } -} diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php new file mode 100644 index 00000000..9afcfa26 --- /dev/null +++ b/application/api/controllers/HistoryController.php @@ -0,0 +1,69 @@ +history->getHistory(); + + // Return history operations from the {offset}th, starting from {since}. + $since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since')); + $offset = $request->getParam('offset'); + if (empty($offset)) { + $offset = 0; + } elseif (ctype_digit($offset)) { + $offset = (int) $offset; + } else { + throw new ApiBadParametersException('Invalid offset'); + } + + // limit parameter is either a number of links or 'all' for everything. + $limit = $request->getParam('limit'); + if (empty($limit)) { + $limit = count($history); + } elseif (ctype_digit($limit)) { + $limit = (int) $limit; + } else { + throw new ApiBadParametersException('Invalid limit'); + } + + $out = []; + $i = 0; + foreach ($history as $entry) { + if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) { + break; + } + if (++$i > $offset) { + $out[$i] = $entry; + $out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM); + } + } + $out = array_values($out); + + return $response->withJson($out, 200, $this->jsonStyle); + } +} diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php index 6dd78750..82f3ef74 100644 --- a/application/api/controllers/Tags.php +++ b/application/api/controllers/Tags.php @@ -4,7 +4,6 @@ namespace Shaarli\Api\Controllers; use Shaarli\Api\ApiUtils; use Shaarli\Api\Exceptions\ApiBadParametersException; -use Shaarli\Api\Exceptions\ApiLinkNotFoundException; use Shaarli\Api\Exceptions\ApiTagNotFoundException; use Slim\Http\Request; use Slim\Http\Response; diff --git a/application/api/exceptions/ApiLinkNotFoundException.php b/application/api/exceptions/ApiLinkNotFoundException.php index c727f4f0..7c2bb56e 100644 --- a/application/api/exceptions/ApiLinkNotFoundException.php +++ b/application/api/exceptions/ApiLinkNotFoundException.php @@ -2,8 +2,6 @@ namespace Shaarli\Api\Exceptions; -use Slim\Http\Response; - /** * Class ApiLinkNotFoundException * diff --git a/application/api/exceptions/ApiTagNotFoundException.php b/application/api/exceptions/ApiTagNotFoundException.php index eee152fe..66ace8bf 100644 --- a/application/api/exceptions/ApiTagNotFoundException.php +++ b/application/api/exceptions/ApiTagNotFoundException.php @@ -2,8 +2,6 @@ namespace Shaarli\Api\Exceptions; -use Slim\Http\Response; - /** * Class ApiTagNotFoundException * diff --git a/application/bookmark/LinkDB.php b/application/bookmark/LinkDB.php index 6041c088..c13a1141 100644 --- a/application/bookmark/LinkDB.php +++ b/application/bookmark/LinkDB.php @@ -6,7 +6,6 @@ use ArrayAccess; use Countable; use DateTime; use Iterator; -use Shaarli\Bookmark\LinkFilter; use Shaarli\Bookmark\Exception\LinkNotFoundException; use Shaarli\Exceptions\IOException; use Shaarli\FileUtils; diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 737a3128..b66f2f91 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -2,7 +2,6 @@ namespace Shaarli\Feed; use DateTime; -use Shaarli\Bookmark\LinkDB; /** * FeedBuilder class. diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index 2bf928c2..2fb1a4a6 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -5,12 +5,12 @@ namespace Shaarli\Netscape; use DateTime; use DateTimeZone; use Exception; +use Katzgrau\KLogger\Logger; use Psr\Log\LogLevel; use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; use Shaarli\History; use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; -use Katzgrau\KLogger\Logger; /** * Utilities to import and export bookmarks using the Netscape format diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 1c5b9251..0569b67f 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -2,10 +2,10 @@ namespace Shaarli\Render; -use Shaarli\ApplicationUtils; use Exception; -use Shaarli\Bookmark\LinkDB; use RainTPL; +use Shaarli\ApplicationUtils; +use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; use Shaarli\Thumbnailer; diff --git a/application/updater/Updater.php b/application/updater/Updater.php index 89f0ff7f..f12e3516 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -2,12 +2,12 @@ namespace Shaarli\Updater; -use Shaarli\ApplicationUtils; use Exception; use RainTPL; use ReflectionClass; use ReflectionException; use ReflectionMethod; +use Shaarli\ApplicationUtils; use Shaarli\Bookmark\LinkDB; use Shaarli\Bookmark\LinkFilter; use Shaarli\Config\ConfigJson; diff --git a/tests/LanguagesTest.php b/tests/LanguagesTest.php index 4951e09a..de83f291 100644 --- a/tests/LanguagesTest.php +++ b/tests/LanguagesTest.php @@ -7,7 +7,7 @@ use Shaarli\Config\ConfigManager; /** * Class LanguagesTest. */ -class LanguagesTest extends \PHPUnit_Framework_TestCase +class LanguagesTest extends \PHPUnit\Framework\TestCase { /** * @var string Config file path (without extension). diff --git a/tests/TimeZoneTest.php b/tests/TimeZoneTest.php index 127fdc19..02bf060f 100644 --- a/tests/TimeZoneTest.php +++ b/tests/TimeZoneTest.php @@ -8,7 +8,7 @@ require_once 'application/TimeZone.php'; /** * Unitary tests for timezone utilities */ -class TimeZoneTest extends PHPUnit_Framework_TestCase +class TimeZoneTest extends PHPUnit\Framework\TestCase { /** * @var array of timezones diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index d0abd996..8225d95a 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -10,7 +10,7 @@ require_once 'application/Languages.php'; /** * Unitary tests for Shaarli utilities */ -class UtilsTest extends PHPUnit_Framework_TestCase +class UtilsTest extends PHPUnit\Framework\TestCase { // Log file protected static $testLogFile = 'tests.log'; diff --git a/tests/api/ApiMiddlewareTest.php b/tests/api/ApiMiddlewareTest.php index 23a56b1c..0b9b03f2 100644 --- a/tests/api/ApiMiddlewareTest.php +++ b/tests/api/ApiMiddlewareTest.php @@ -2,7 +2,6 @@ namespace Shaarli\Api; use Shaarli\Config\ConfigManager; - use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; @@ -18,7 +17,7 @@ use Slim\Http\Response; * * @package Api */ -class ApiMiddlewareTest extends \PHPUnit_Framework_TestCase +class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations diff --git a/tests/api/ApiUtilsTest.php b/tests/api/ApiUtilsTest.php index a1b623d8..ea0ae500 100644 --- a/tests/api/ApiUtilsTest.php +++ b/tests/api/ApiUtilsTest.php @@ -7,7 +7,7 @@ use Shaarli\Http\Base64Url; /** * Class ApiUtilsTest */ -class ApiUtilsTest extends \PHPUnit_Framework_TestCase +class ApiUtilsTest extends \PHPUnit\Framework\TestCase { /** * Force the timezone for ISO datetimes. diff --git a/tests/api/controllers/history/HistoryTest.php b/tests/api/controllers/history/HistoryTest.php index 24efee89..e287f239 100644 --- a/tests/api/controllers/history/HistoryTest.php +++ b/tests/api/controllers/history/HistoryTest.php @@ -1,9 +1,9 @@ container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = true; - $this->container['history'] = new \Shaarli\History(self::$testHistory); + $this->container['history'] = new History(self::$testHistory); - $this->controller = new History($this->container); + $this->controller = new HistoryController($this->container); } /** @@ -78,35 +78,35 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals($this->refHistory->count(), count($data)); - $this->assertEquals(\Shaarli\History::DELETED, $data[0]['event']); + $this->assertEquals(History::DELETED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170303_121216')->format(\DateTime::ATOM), $data[0]['datetime'] ); $this->assertEquals(124, $data[0]['id']); - $this->assertEquals(\Shaarli\History::SETTINGS, $data[1]['event']); + $this->assertEquals(History::SETTINGS, $data[1]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170302_121215')->format(\DateTime::ATOM), $data[1]['datetime'] ); $this->assertNull($data[1]['id']); - $this->assertEquals(\Shaarli\History::UPDATED, $data[2]['event']); + $this->assertEquals(History::UPDATED, $data[2]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170301_121214')->format(\DateTime::ATOM), $data[2]['datetime'] ); $this->assertEquals(123, $data[2]['id']); - $this->assertEquals(\Shaarli\History::CREATED, $data[3]['event']); + $this->assertEquals(History::CREATED, $data[3]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170201_121214')->format(\DateTime::ATOM), $data[3]['datetime'] ); $this->assertEquals(124, $data[3]['id']); - $this->assertEquals(\Shaarli\History::CREATED, $data[4]['event']); + $this->assertEquals(History::CREATED, $data[4]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170101_121212')->format(\DateTime::ATOM), $data[4]['datetime'] @@ -131,7 +131,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\Shaarli\History::DELETED, $data[0]['event']); + $this->assertEquals(History::DELETED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170303_121216')->format(\DateTime::ATOM), $data[0]['datetime'] @@ -156,7 +156,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\Shaarli\History::CREATED, $data[0]['event']); + $this->assertEquals(History::CREATED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170101_121212')->format(\DateTime::ATOM), $data[0]['datetime'] @@ -181,7 +181,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\Shaarli\History::DELETED, $data[0]['event']); + $this->assertEquals(History::DELETED, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170303_121216')->format(\DateTime::ATOM), $data[0]['datetime'] @@ -206,7 +206,7 @@ class HistoryTest extends \PHPUnit_Framework_TestCase $this->assertEquals(1, count($data)); - $this->assertEquals(\Shaarli\History::SETTINGS, $data[0]['event']); + $this->assertEquals(History::SETTINGS, $data[0]['event']); $this->assertEquals( \DateTime::createFromFormat('Ymd_His', '20170302_121215')->format(\DateTime::ATOM), $data[0]['datetime'] diff --git a/tests/api/controllers/info/InfoTest.php b/tests/api/controllers/info/InfoTest.php index 44a9382e..e70d371b 100644 --- a/tests/api/controllers/info/InfoTest.php +++ b/tests/api/controllers/info/InfoTest.php @@ -2,7 +2,6 @@ namespace Shaarli\Api\Controllers; use Shaarli\Config\ConfigManager; - use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; @@ -15,7 +14,7 @@ use Slim\Http\Response; * * @package Api\Controllers */ -class InfoTest extends \PHPUnit_Framework_TestCase +class InfoTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations diff --git a/tests/api/controllers/links/DeleteLinkTest.php b/tests/api/controllers/links/DeleteLinkTest.php index adca9a4e..90193e28 100644 --- a/tests/api/controllers/links/DeleteLinkTest.php +++ b/tests/api/controllers/links/DeleteLinkTest.php @@ -3,13 +3,15 @@ namespace Shaarli\Api\Controllers; +use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; +use Shaarli\History; use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; use Slim\Http\Response; -class DeleteLinkTest extends \PHPUnit_Framework_TestCase +class DeleteLinkTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations @@ -32,12 +34,12 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \Shaarli\Bookmark\LinkDB instance. + * @var LinkDB instance. */ protected $linkDB; /** - * @var \Shaarli\History instance. + * @var HistoryController instance. */ protected $history; @@ -59,10 +61,10 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \Shaarli\History(self::$testHistory); + $this->history = new History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = $this->linkDB; @@ -96,11 +98,11 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $this->assertFalse(isset($this->linkDB[$id])); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\Shaarli\History::DELETED, $historyEntry['event']); + $this->assertEquals(History::DELETED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); @@ -110,7 +112,7 @@ class DeleteLinkTest extends \PHPUnit_Framework_TestCase /** * Test DELETE link endpoint: reach not existing ID. * - * @expectedException Shaarli\Api\Exceptions\ApiLinkNotFoundException + * @expectedException \Shaarli\Api\Exceptions\ApiLinkNotFoundException */ public function testDeleteLink404() { diff --git a/tests/api/controllers/links/GetLinkIdTest.php b/tests/api/controllers/links/GetLinkIdTest.php index bf58000b..cb9b7f6a 100644 --- a/tests/api/controllers/links/GetLinkIdTest.php +++ b/tests/api/controllers/links/GetLinkIdTest.php @@ -3,7 +3,6 @@ namespace Shaarli\Api\Controllers; use Shaarli\Config\ConfigManager; - use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; @@ -18,7 +17,7 @@ use Slim\Http\Response; * * @package Shaarli\Api\Controllers */ -class GetLinkIdTest extends \PHPUnit_Framework_TestCase +class GetLinkIdTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations diff --git a/tests/api/controllers/links/GetLinksTest.php b/tests/api/controllers/links/GetLinksTest.php index 1008d7b2..711a3152 100644 --- a/tests/api/controllers/links/GetLinksTest.php +++ b/tests/api/controllers/links/GetLinksTest.php @@ -1,8 +1,8 @@ container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new LinkDB(self::$testDatastore, true, false); $this->container['history'] = null; $this->controller = new Links($this->container); @@ -114,7 +114,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase $this->assertEquals('sTuff', $first['tags'][0]); $this->assertEquals(false, $first['private']); $this->assertEquals( - \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), + \DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651')->format(\DateTime::ATOM), $first['created'] ); $this->assertEmpty($first['updated']); @@ -125,7 +125,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase // Update date $this->assertEquals( - \DateTime::createFromFormat(\Shaarli\Bookmark\LinkDB::LINK_DATE_FORMAT, '20160803_093033')->format(\DateTime::ATOM), + \DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160803_093033')->format(\DateTime::ATOM), $link['updated'] ); } diff --git a/tests/api/controllers/links/PostLinkTest.php b/tests/api/controllers/links/PostLinkTest.php index 24e591db..d683a984 100644 --- a/tests/api/controllers/links/PostLinkTest.php +++ b/tests/api/controllers/links/PostLinkTest.php @@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers; use PHPUnit\Framework\TestCase; use Shaarli\Config\ConfigManager; +use Shaarli\History; use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; @@ -40,7 +41,7 @@ class PostLinkTest extends TestCase protected $refDB = null; /** - * @var \Shaarli\History instance. + * @var HistoryController instance. */ protected $history; @@ -70,12 +71,12 @@ class PostLinkTest extends TestCase $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \Shaarli\History(self::$testHistory); + $this->history = new History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); - $this->container['history'] = new \Shaarli\History(self::$testHistory); + $this->container['history'] = new History(self::$testHistory); $this->controller = new Links($this->container); @@ -133,7 +134,7 @@ class PostLinkTest extends TestCase $this->assertEquals('', $data['updated']); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\Shaarli\History::CREATED, $historyEntry['event']); + $this->assertEquals(History::CREATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/links/PutLinkTest.php b/tests/api/controllers/links/PutLinkTest.php index eb6c7955..cd815b66 100644 --- a/tests/api/controllers/links/PutLinkTest.php +++ b/tests/api/controllers/links/PutLinkTest.php @@ -4,12 +4,13 @@ namespace Shaarli\Api\Controllers; use Shaarli\Config\ConfigManager; +use Shaarli\History; use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; use Slim\Http\Response; -class PutLinkTest extends \PHPUnit_Framework_TestCase +class PutLinkTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations @@ -32,7 +33,7 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \Shaarli\History instance. + * @var HistoryController instance. */ protected $history; @@ -62,12 +63,12 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \Shaarli\History(self::$testHistory); + $this->history = new History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); - $this->container['history'] = new \Shaarli\History(self::$testHistory); + $this->container['history'] = new History(self::$testHistory); $this->controller = new Links($this->container); @@ -119,7 +120,7 @@ class PutLinkTest extends \PHPUnit_Framework_TestCase ); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); + $this->assertEquals(History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/tags/DeleteTagTest.php b/tests/api/controllers/tags/DeleteTagTest.php index 02803ba2..84e1d56e 100644 --- a/tests/api/controllers/tags/DeleteTagTest.php +++ b/tests/api/controllers/tags/DeleteTagTest.php @@ -3,13 +3,15 @@ namespace Shaarli\Api\Controllers; +use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; +use Shaarli\History; use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; use Slim\Http\Response; -class DeleteTagTest extends \PHPUnit_Framework_TestCase +class DeleteTagTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations @@ -32,12 +34,12 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase protected $refDB = null; /** - * @var \Shaarli\Bookmark\LinkDB instance. + * @var LinkDB instance. */ protected $linkDB; /** - * @var \Shaarli\History instance. + * @var HistoryController instance. */ protected $history; @@ -59,10 +61,10 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); - $this->history = new \Shaarli\History(self::$testHistory); + $this->history = new History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; $this->container['db'] = $this->linkDB; @@ -97,18 +99,18 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $tags = $this->linkDB->linksCountPerTag(); $this->assertFalse(isset($tags[$tagName])); // 2 links affected $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); + $this->assertEquals(History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); $historyEntry = $this->history->getHistory()[1]; - $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); + $this->assertEquals(History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); @@ -131,13 +133,13 @@ class DeleteTagTest extends \PHPUnit_Framework_TestCase $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $tags = $this->linkDB->linksCountPerTag(); $this->assertFalse(isset($tags[$tagName])); $this->assertTrue($tags[strtolower($tagName)] > 0); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); + $this->assertEquals(History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/api/controllers/tags/GetTagNameTest.php b/tests/api/controllers/tags/GetTagNameTest.php index 8e0feccd..a2525c17 100644 --- a/tests/api/controllers/tags/GetTagNameTest.php +++ b/tests/api/controllers/tags/GetTagNameTest.php @@ -2,8 +2,8 @@ namespace Shaarli\Api\Controllers; +use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; - use Slim\Container; use Slim\Http\Environment; use Slim\Http\Request; @@ -16,7 +16,7 @@ use Slim\Http\Response; * * @package Shaarli\Api\Controllers */ -class GetTagNameTest extends \PHPUnit_Framework_TestCase +class GetTagNameTest extends \PHPUnit\Framework\TestCase { /** * @var string datastore to test write operations @@ -59,7 +59,7 @@ class GetTagNameTest extends \PHPUnit_Framework_TestCase $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->container['db'] = new LinkDB(self::$testDatastore, true, false); $this->container['history'] = null; $this->controller = new Tags($this->container); diff --git a/tests/api/controllers/tags/GetTagsTest.php b/tests/api/controllers/tags/GetTagsTest.php index f071bfa8..98628c98 100644 --- a/tests/api/controllers/tags/GetTagsTest.php +++ b/tests/api/controllers/tags/GetTagsTest.php @@ -1,8 +1,8 @@ container = new Container(); $this->container['conf'] = $this->conf; - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $this->container['db'] = $this->linkDB; $this->container['history'] = null; diff --git a/tests/api/controllers/tags/PutTagTest.php b/tests/api/controllers/tags/PutTagTest.php index d8c0fec8..86106fc7 100644 --- a/tests/api/controllers/tags/PutTagTest.php +++ b/tests/api/controllers/tags/PutTagTest.php @@ -1,16 +1,17 @@ write(self::$testHistory); - $this->history = new \Shaarli\History(self::$testHistory); + $this->history = new History(self::$testHistory); $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->linkDB = new \Shaarli\Bookmark\LinkDB(self::$testDatastore, true, false); + $this->linkDB = new LinkDB(self::$testDatastore, true, false); $this->container['db'] = $this->linkDB; $this->container['history'] = $this->history; @@ -113,12 +114,12 @@ class PutTagTest extends \PHPUnit_Framework_TestCase $this->assertEquals(2, $tags[$newName]); $historyEntry = $this->history->getHistory()[0]; - $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); + $this->assertEquals(History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); $historyEntry = $this->history->getHistory()[1]; - $this->assertEquals(\Shaarli\History::UPDATED, $historyEntry['event']); + $this->assertEquals(History::UPDATED, $historyEntry['event']); $this->assertTrue( (new \DateTime())->add(\DateInterval::createFromDateString('-5 seconds')) < $historyEntry['datetime'] ); diff --git a/tests/bookmark/LinkDBTest.php b/tests/bookmark/LinkDBTest.php index 65409e95..ff5c0b97 100644 --- a/tests/bookmark/LinkDBTest.php +++ b/tests/bookmark/LinkDBTest.php @@ -6,7 +6,6 @@ namespace Shaarli\Bookmark; use DateTime; -use Shaarli\Bookmark\Exception\LinkNotFoundException; use ReferenceLinkDB; use ReflectionClass; use Shaarli; diff --git a/tests/config/ConfigJsonTest.php b/tests/config/ConfigJsonTest.php index 99c1ea56..95ad060b 100644 --- a/tests/config/ConfigJsonTest.php +++ b/tests/config/ConfigJsonTest.php @@ -4,7 +4,7 @@ namespace Shaarli\Config; /** * Class ConfigJsonTest */ -class ConfigJsonTest extends \PHPUnit_Framework_TestCase +class ConfigJsonTest extends \PHPUnit\Framework\TestCase { /** * @var ConfigJson diff --git a/tests/config/ConfigManagerTest.php b/tests/config/ConfigManagerTest.php index 4a4e94ac..33830bc9 100644 --- a/tests/config/ConfigManagerTest.php +++ b/tests/config/ConfigManagerTest.php @@ -7,7 +7,7 @@ namespace Shaarli\Config; * Note: it only test the manager with ConfigJson, * ConfigPhp is only a workaround to handle the transition to JSON type. */ -class ConfigManagerTest extends \PHPUnit_Framework_TestCase +class ConfigManagerTest extends \PHPUnit\Framework\TestCase { /** * @var ConfigManager diff --git a/tests/config/ConfigPhpTest.php b/tests/config/ConfigPhpTest.php index be23eea1..67d878ce 100644 --- a/tests/config/ConfigPhpTest.php +++ b/tests/config/ConfigPhpTest.php @@ -4,7 +4,7 @@ namespace Shaarli\Config; /** * Class ConfigPhpTest */ -class ConfigPhpTest extends \PHPUnit_Framework_TestCase +class ConfigPhpTest extends \PHPUnit\Framework\TestCase { /** * @var ConfigPhp diff --git a/tests/config/ConfigPluginTest.php b/tests/config/ConfigPluginTest.php index deb02c9e..d7a70e68 100644 --- a/tests/config/ConfigPluginTest.php +++ b/tests/config/ConfigPluginTest.php @@ -8,7 +8,7 @@ require_once 'application/config/ConfigPlugin.php'; /** * Unitary tests for Shaarli config related functions */ -class ConfigPluginTest extends \PHPUnit_Framework_TestCase +class ConfigPluginTest extends \PHPUnit\Framework\TestCase { /** * Test save_plugin_config with valid data. diff --git a/tests/feed/FeedBuilderTest.php b/tests/feed/FeedBuilderTest.php index 88d1c3ed..b496cb4c 100644 --- a/tests/feed/FeedBuilderTest.php +++ b/tests/feed/FeedBuilderTest.php @@ -3,8 +3,8 @@ namespace Shaarli\Feed; use DateTime; -use Shaarli\Bookmark\LinkDB; use ReferenceLinkDB; +use Shaarli\Bookmark\LinkDB; /** * FeedBuilderTest class. diff --git a/tests/languages/fr/LanguagesFrTest.php b/tests/languages/fr/LanguagesFrTest.php index 38347de1..b8b7ca3a 100644 --- a/tests/languages/fr/LanguagesFrTest.php +++ b/tests/languages/fr/LanguagesFrTest.php @@ -12,7 +12,7 @@ use Shaarli\Config\ConfigManager; * * @package Shaarli */ -class LanguagesFrTest extends \PHPUnit_Framework_TestCase +class LanguagesFrTest extends \PHPUnit\Framework\TestCase { /** * @var string Config file path (without extension). diff --git a/tests/plugins/WallabagInstanceTest.php b/tests/plugins/WallabagInstanceTest.php index bcc01604..a3cd9076 100644 --- a/tests/plugins/WallabagInstanceTest.php +++ b/tests/plugins/WallabagInstanceTest.php @@ -1,8 +1,6 @@