aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2020-01-18 10:01:06 +0100
committerGitHub <noreply@github.com>2020-01-18 10:01:06 +0100
commit3fb29fdda04ca86e04422d49b86cf646d53c4f9d (patch)
treeadf8512f93f5559ba87d0c9931969ae4ebea7133 /application
parent796c4c57d085ae4589b53dfe8369ae9ba30ffdaf (diff)
parente26e2060f5470ce8bf4c5973284bae07b8af170a (diff)
downloadShaarli-3fb29fdda04ca86e04422d49b86cf646d53c4f9d.tar.gz
Shaarli-3fb29fdda04ca86e04422d49b86cf646d53c4f9d.tar.zst
Shaarli-3fb29fdda04ca86e04422d49b86cf646d53c4f9d.zip
Store bookmarks as PHP objects and add a service layer to retriā€¦ (#1307)
Store bookmarks as PHP objects and add a service layer to retrieve them
Diffstat (limited to 'application')
-rw-r--r--application/History.php17
-rw-r--r--application/Utils.php2
-rw-r--r--application/api/ApiMiddleware.php11
-rw-r--r--application/api/ApiUtils.php79
-rw-r--r--application/api/controllers/ApiController.php8
-rw-r--r--application/api/controllers/HistoryController.php2
-rw-r--r--application/api/controllers/Info.php5
-rw-r--r--application/api/controllers/Links.php79
-rw-r--r--application/api/controllers/Tags.php44
-rw-r--r--application/bookmark/Bookmark.php461
-rw-r--r--application/bookmark/BookmarkArray.php259
-rw-r--r--application/bookmark/BookmarkFileService.php373
-rw-r--r--application/bookmark/BookmarkFilter.php468
-rw-r--r--application/bookmark/BookmarkIO.php108
-rw-r--r--application/bookmark/BookmarkInitializer.php59
-rw-r--r--application/bookmark/BookmarkServiceInterface.php180
-rw-r--r--application/bookmark/LinkUtils.php27
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php (renamed from application/bookmark/exception/LinkNotFoundException.php)2
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php7
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php30
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php19
-rw-r--r--application/config/ConfigManager.php2
-rw-r--r--application/feed/FeedBuilder.php78
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php81
-rw-r--r--application/formatter/BookmarkFormatter.php256
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php204
-rw-r--r--application/formatter/BookmarkRawFormatter.php13
-rw-r--r--application/formatter/FormatterFactory.php46
-rw-r--r--application/legacy/LegacyLinkDB.php (renamed from application/bookmark/LinkDB.php)68
-rw-r--r--application/legacy/LegacyLinkFilter.php (renamed from application/bookmark/LinkFilter.php)20
-rw-r--r--application/legacy/LegacyUpdater.php617
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php117
-rw-r--r--application/render/PageBuilder.php24
-rw-r--r--application/updater/Updater.php480
-rw-r--r--application/updater/UpdaterUtils.php65
35 files changed, 3512 insertions, 799 deletions
diff --git a/application/History.php b/application/History.php
index a5846652..4fd2f294 100644
--- a/application/History.php
+++ b/application/History.php
@@ -3,6 +3,7 @@ namespace Shaarli;
3 3
4use DateTime; 4use DateTime;
5use Exception; 5use Exception;
6use Shaarli\Bookmark\Bookmark;
6 7
7/** 8/**
8 * Class History 9 * Class History
@@ -20,7 +21,7 @@ use Exception;
20 * - UPDATED: link updated 21 * - UPDATED: link updated
21 * - DELETED: link deleted 22 * - DELETED: link deleted
22 * - SETTINGS: the settings have been updated through the UI. 23 * - SETTINGS: the settings have been updated through the UI.
23 * - IMPORT: bulk links import 24 * - IMPORT: bulk bookmarks import
24 * 25 *
25 * Note: new events are put at the beginning of the file and history array. 26 * Note: new events are put at the beginning of the file and history array.
26 */ 27 */
@@ -96,31 +97,31 @@ class History
96 /** 97 /**
97 * Add Event: new link. 98 * Add Event: new link.
98 * 99 *
99 * @param array $link Link data. 100 * @param Bookmark $link Link data.
100 */ 101 */
101 public function addLink($link) 102 public function addLink($link)
102 { 103 {
103 $this->addEvent(self::CREATED, $link['id']); 104 $this->addEvent(self::CREATED, $link->getId());
104 } 105 }
105 106
106 /** 107 /**
107 * Add Event: update existing link. 108 * Add Event: update existing link.
108 * 109 *
109 * @param array $link Link data. 110 * @param Bookmark $link Link data.
110 */ 111 */
111 public function updateLink($link) 112 public function updateLink($link)
112 { 113 {
113 $this->addEvent(self::UPDATED, $link['id']); 114 $this->addEvent(self::UPDATED, $link->getId());
114 } 115 }
115 116
116 /** 117 /**
117 * Add Event: delete existing link. 118 * Add Event: delete existing link.
118 * 119 *
119 * @param array $link Link data. 120 * @param Bookmark $link Link data.
120 */ 121 */
121 public function deleteLink($link) 122 public function deleteLink($link)
122 { 123 {
123 $this->addEvent(self::DELETED, $link['id']); 124 $this->addEvent(self::DELETED, $link->getId());
124 } 125 }
125 126
126 /** 127 /**
@@ -134,7 +135,7 @@ class History
134 /** 135 /**
135 * Add Event: bulk import. 136 * Add Event: bulk import.
136 * 137 *
137 * Note: we don't store links add/update one by one since it can have a huge impact on performances. 138 * Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
138 */ 139 */
139 public function importLinks() 140 public function importLinks()
140 { 141 {
diff --git a/application/Utils.php b/application/Utils.php
index 925e1a22..56f5b9a2 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -162,7 +162,7 @@ function generateLocation($referer, $host, $loopTerms = array())
162 $finalReferer = '?'; 162 $finalReferer = '?';
163 163
164 // No referer if it contains any value in $loopCriteria. 164 // No referer if it contains any value in $loopCriteria.
165 foreach ($loopTerms as $value) { 165 foreach (array_filter($loopTerms) as $value) {
166 if (strpos($referer, $value) !== false) { 166 if (strpos($referer, $value) !== false) {
167 return $finalReferer; 167 return $finalReferer;
168 } 168 }
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index 2d55bda6..4745ac94 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -3,6 +3,7 @@ namespace Shaarli\Api;
3 3
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 4use Shaarli\Api\Exceptions\ApiAuthorizationException;
5use Shaarli\Api\Exceptions\ApiException; 5use Shaarli\Api\Exceptions\ApiException;
6use Shaarli\Bookmark\BookmarkFileService;
6use Shaarli\Config\ConfigManager; 7use Shaarli\Config\ConfigManager;
7use Slim\Container; 8use Slim\Container;
8use Slim\Http\Request; 9use Slim\Http\Request;
@@ -117,7 +118,7 @@ class ApiMiddleware
117 } 118 }
118 119
119 /** 120 /**
120 * Instantiate a new LinkDB including private links, 121 * Instantiate a new LinkDB including private bookmarks,
121 * and load in the Slim container. 122 * and load in the Slim container.
122 * 123 *
123 * FIXME! LinkDB could use a refactoring to avoid this trick. 124 * FIXME! LinkDB could use a refactoring to avoid this trick.
@@ -126,10 +127,10 @@ class ApiMiddleware
126 */ 127 */
127 protected function setLinkDb($conf) 128 protected function setLinkDb($conf)
128 { 129 {
129 $linkDb = new \Shaarli\Bookmark\LinkDB( 130 $linkDb = new BookmarkFileService(
130 $conf->get('resource.datastore'), 131 $conf,
131 true, 132 $this->container->get('history'),
132 $conf->get('privacy.hide_public_links') 133 true
133 ); 134 );
134 $this->container['db'] = $linkDb; 135 $this->container['db'] = $linkDb;
135 } 136 }
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index 5ac07c4d..5156a5f7 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -2,6 +2,7 @@
2namespace Shaarli\Api; 2namespace Shaarli\Api;
3 3
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 4use Shaarli\Api\Exceptions\ApiAuthorizationException;
5use Shaarli\Bookmark\Bookmark;
5use Shaarli\Http\Base64Url; 6use Shaarli\Http\Base64Url;
6 7
7/** 8/**
@@ -54,28 +55,28 @@ class ApiUtils
54 /** 55 /**
55 * Format a Link for the REST API. 56 * Format a Link for the REST API.
56 * 57 *
57 * @param array $link Link data read from the datastore. 58 * @param Bookmark $bookmark Bookmark data read from the datastore.
58 * @param string $indexUrl Shaarli's index URL (used for relative URL). 59 * @param string $indexUrl Shaarli's index URL (used for relative URL).
59 * 60 *
60 * @return array Link data formatted for the REST API. 61 * @return array Link data formatted for the REST API.
61 */ 62 */
62 public static function formatLink($link, $indexUrl) 63 public static function formatLink($bookmark, $indexUrl)
63 { 64 {
64 $out['id'] = $link['id']; 65 $out['id'] = $bookmark->getId();
65 // Not an internal link 66 // Not an internal link
66 if (! is_note($link['url'])) { 67 if (! $bookmark->isNote()) {
67 $out['url'] = $link['url']; 68 $out['url'] = $bookmark->getUrl();
68 } else { 69 } else {
69 $out['url'] = $indexUrl . $link['url']; 70 $out['url'] = $indexUrl . $bookmark->getUrl();
70 } 71 }
71 $out['shorturl'] = $link['shorturl']; 72 $out['shorturl'] = $bookmark->getShortUrl();
72 $out['title'] = $link['title']; 73 $out['title'] = $bookmark->getTitle();
73 $out['description'] = $link['description']; 74 $out['description'] = $bookmark->getDescription();
74 $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); 75 $out['tags'] = $bookmark->getTags();
75 $out['private'] = $link['private'] == true; 76 $out['private'] = $bookmark->isPrivate();
76 $out['created'] = $link['created']->format(\DateTime::ATOM); 77 $out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
77 if (! empty($link['updated'])) { 78 if (! empty($bookmark->getUpdated())) {
78 $out['updated'] = $link['updated']->format(\DateTime::ATOM); 79 $out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
79 } else { 80 } else {
80 $out['updated'] = ''; 81 $out['updated'] = '';
81 } 82 }
@@ -83,7 +84,7 @@ class ApiUtils
83 } 84 }
84 85
85 /** 86 /**
86 * Convert a link given through a request, to a valid link for LinkDB. 87 * Convert a link given through a request, to a valid Bookmark for the datastore.
87 * 88 *
88 * If no URL is provided, it will generate a local note URL. 89 * If no URL is provided, it will generate a local note URL.
89 * If no title is provided, it will use the URL as title. 90 * If no title is provided, it will use the URL as title.
@@ -91,50 +92,42 @@ class ApiUtils
91 * @param array $input Request Link. 92 * @param array $input Request Link.
92 * @param bool $defaultPrivate Request Link. 93 * @param bool $defaultPrivate Request Link.
93 * 94 *
94 * @return array Formatted link. 95 * @return Bookmark instance.
95 */ 96 */
96 public static function buildLinkFromRequest($input, $defaultPrivate) 97 public static function buildLinkFromRequest($input, $defaultPrivate)
97 { 98 {
98 $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; 99 $bookmark = new Bookmark();
100 $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
99 if (isset($input['private'])) { 101 if (isset($input['private'])) {
100 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); 102 $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
101 } else { 103 } else {
102 $private = $defaultPrivate; 104 $private = $defaultPrivate;
103 } 105 }
104 106
105 $link = [ 107 $bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
106 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], 108 $bookmark->setUrl($url);
107 'url' => $input['url'], 109 $bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
108 'description' => ! empty($input['description']) ? $input['description'] : '', 110 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
109 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', 111 $bookmark->setPrivate($private);
110 'private' => $private, 112
111 'created' => new \DateTime(), 113 return $bookmark;
112 ];
113 return $link;
114 } 114 }
115 115
116 /** 116 /**
117 * Update link fields using an updated link object. 117 * Update link fields using an updated link object.
118 * 118 *
119 * @param array $oldLink data 119 * @param Bookmark $oldLink data
120 * @param array $newLink data 120 * @param Bookmark $newLink data
121 * 121 *
122 * @return array $oldLink updated with $newLink values 122 * @return Bookmark $oldLink updated with $newLink values
123 */ 123 */
124 public static function updateLink($oldLink, $newLink) 124 public static function updateLink($oldLink, $newLink)
125 { 125 {
126 foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { 126 $oldLink->setTitle($newLink->getTitle());
127 $oldLink[$field] = $newLink[$field]; 127 $oldLink->setUrl($newLink->getUrl());
128 } 128 $oldLink->setDescription($newLink->getDescription());
129 $oldLink['updated'] = new \DateTime(); 129 $oldLink->setTags($newLink->getTags());
130 130 $oldLink->setPrivate($newLink->isPrivate());
131 if (empty($oldLink['url'])) {
132 $oldLink['url'] = '?' . $oldLink['shorturl'];
133 }
134
135 if (empty($oldLink['title'])) {
136 $oldLink['title'] = $oldLink['url'];
137 }
138 131
139 return $oldLink; 132 return $oldLink;
140 } 133 }
@@ -143,7 +136,7 @@ class ApiUtils
143 * Format a Tag for the REST API. 136 * Format a Tag for the REST API.
144 * 137 *
145 * @param string $tag Tag name 138 * @param string $tag Tag name
146 * @param int $occurrences Number of links using this tag 139 * @param int $occurrences Number of bookmarks using this tag
147 * 140 *
148 * @return array Link data formatted for the REST API. 141 * @return array Link data formatted for the REST API.
149 */ 142 */
diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php
index a6e7cbab..c4b3d0c3 100644
--- a/application/api/controllers/ApiController.php
+++ b/application/api/controllers/ApiController.php
@@ -2,7 +2,7 @@
2 2
3namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
4 4
5use Shaarli\Bookmark\LinkDB; 5use Shaarli\Bookmark\BookmarkServiceInterface;
6use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
7use Slim\Container; 7use Slim\Container;
8 8
@@ -26,9 +26,9 @@ abstract class ApiController
26 protected $conf; 26 protected $conf;
27 27
28 /** 28 /**
29 * @var LinkDB 29 * @var BookmarkServiceInterface
30 */ 30 */
31 protected $linkDb; 31 protected $bookmarkService;
32 32
33 /** 33 /**
34 * @var HistoryController 34 * @var HistoryController
@@ -51,7 +51,7 @@ abstract class ApiController
51 { 51 {
52 $this->ci = $ci; 52 $this->ci = $ci;
53 $this->conf = $ci->get('conf'); 53 $this->conf = $ci->get('conf');
54 $this->linkDb = $ci->get('db'); 54 $this->bookmarkService = $ci->get('db');
55 $this->history = $ci->get('history'); 55 $this->history = $ci->get('history');
56 if ($this->conf->get('dev.debug', false)) { 56 if ($this->conf->get('dev.debug', false)) {
57 $this->jsonStyle = JSON_PRETTY_PRINT; 57 $this->jsonStyle = JSON_PRETTY_PRINT;
diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php
index 9afcfa26..505647a9 100644
--- a/application/api/controllers/HistoryController.php
+++ b/application/api/controllers/HistoryController.php
@@ -41,7 +41,7 @@ class HistoryController extends ApiController
41 throw new ApiBadParametersException('Invalid offset'); 41 throw new ApiBadParametersException('Invalid offset');
42 } 42 }
43 43
44 // limit parameter is either a number of links or 'all' for everything. 44 // limit parameter is either a number of bookmarks or 'all' for everything.
45 $limit = $request->getParam('limit'); 45 $limit = $request->getParam('limit');
46 if (empty($limit)) { 46 if (empty($limit)) {
47 $limit = count($history); 47 $limit = count($history);
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
index f37dcae5..12f6b2f0 100644
--- a/application/api/controllers/Info.php
+++ b/application/api/controllers/Info.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
4 4
5use Shaarli\Bookmark\BookmarkFilter;
5use Slim\Http\Request; 6use Slim\Http\Request;
6use Slim\Http\Response; 7use Slim\Http\Response;
7 8
@@ -26,8 +27,8 @@ class Info extends ApiController
26 public function getInfo($request, $response) 27 public function getInfo($request, $response)
27 { 28 {
28 $info = [ 29 $info = [
29 'global_counter' => count($this->linkDb), 30 'global_counter' => $this->bookmarkService->count(),
30 'private_counter' => count_private($this->linkDb), 31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
31 'settings' => array( 32 'settings' => array(
32 'title' => $this->conf->get('general.title', 'Shaarli'), 33 'title' => $this->conf->get('general.title', 'Shaarli'),
33 'header_link' => $this->conf->get('general.header_link', '?'), 34 'header_link' => $this->conf->get('general.header_link', '?'),
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index ffcfd4c7..29247950 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -11,7 +11,7 @@ use Slim\Http\Response;
11/** 11/**
12 * Class Links 12 * Class Links
13 * 13 *
14 * REST API Controller: all services related to links collection. 14 * REST API Controller: all services related to bookmarks collection.
15 * 15 *
16 * @package Api\Controllers 16 * @package Api\Controllers
17 * @see http://shaarli.github.io/api-documentation/#links-links-collection 17 * @see http://shaarli.github.io/api-documentation/#links-links-collection
@@ -19,12 +19,12 @@ use Slim\Http\Response;
19class Links extends ApiController 19class Links extends ApiController
20{ 20{
21 /** 21 /**
22 * @var int Number of links returned if no limit is provided. 22 * @var int Number of bookmarks returned if no limit is provided.
23 */ 23 */
24 public static $DEFAULT_LIMIT = 20; 24 public static $DEFAULT_LIMIT = 20;
25 25
26 /** 26 /**
27 * Retrieve a list of links, allowing different filters. 27 * Retrieve a list of bookmarks, allowing different filters.
28 * 28 *
29 * @param Request $request Slim request. 29 * @param Request $request Slim request.
30 * @param Response $response Slim response. 30 * @param Response $response Slim response.
@@ -36,33 +36,32 @@ class Links extends ApiController
36 public function getLinks($request, $response) 36 public function getLinks($request, $response)
37 { 37 {
38 $private = $request->getParam('visibility'); 38 $private = $request->getParam('visibility');
39 $links = $this->linkDb->filterSearch( 39 $bookmarks = $this->bookmarkService->search(
40 [ 40 [
41 'searchtags' => $request->getParam('searchtags', ''), 41 'searchtags' => $request->getParam('searchtags', ''),
42 'searchterm' => $request->getParam('searchterm', ''), 42 'searchterm' => $request->getParam('searchterm', ''),
43 ], 43 ],
44 false,
45 $private 44 $private
46 ); 45 );
47 46
48 // Return links from the {offset}th link, starting from 0. 47 // Return bookmarks from the {offset}th link, starting from 0.
49 $offset = $request->getParam('offset'); 48 $offset = $request->getParam('offset');
50 if (! empty($offset) && ! ctype_digit($offset)) { 49 if (! empty($offset) && ! ctype_digit($offset)) {
51 throw new ApiBadParametersException('Invalid offset'); 50 throw new ApiBadParametersException('Invalid offset');
52 } 51 }
53 $offset = ! empty($offset) ? intval($offset) : 0; 52 $offset = ! empty($offset) ? intval($offset) : 0;
54 if ($offset > count($links)) { 53 if ($offset > count($bookmarks)) {
55 return $response->withJson([], 200, $this->jsonStyle); 54 return $response->withJson([], 200, $this->jsonStyle);
56 } 55 }
57 56
58 // limit parameter is either a number of links or 'all' for everything. 57 // limit parameter is either a number of bookmarks or 'all' for everything.
59 $limit = $request->getParam('limit'); 58 $limit = $request->getParam('limit');
60 if (empty($limit)) { 59 if (empty($limit)) {
61 $limit = self::$DEFAULT_LIMIT; 60 $limit = self::$DEFAULT_LIMIT;
62 } elseif (ctype_digit($limit)) { 61 } elseif (ctype_digit($limit)) {
63 $limit = intval($limit); 62 $limit = intval($limit);
64 } elseif ($limit === 'all') { 63 } elseif ($limit === 'all') {
65 $limit = count($links); 64 $limit = count($bookmarks);
66 } else { 65 } else {
67 throw new ApiBadParametersException('Invalid limit'); 66 throw new ApiBadParametersException('Invalid limit');
68 } 67 }
@@ -72,12 +71,12 @@ class Links extends ApiController
72 71
73 $out = []; 72 $out = [];
74 $index = 0; 73 $index = 0;
75 foreach ($links as $link) { 74 foreach ($bookmarks as $bookmark) {
76 if (count($out) >= $limit) { 75 if (count($out) >= $limit) {
77 break; 76 break;
78 } 77 }
79 if ($index++ >= $offset) { 78 if ($index++ >= $offset) {
80 $out[] = ApiUtils::formatLink($link, $indexUrl); 79 $out[] = ApiUtils::formatLink($bookmark, $indexUrl);
81 } 80 }
82 } 81 }
83 82
@@ -97,11 +96,11 @@ class Links extends ApiController
97 */ 96 */
98 public function getLink($request, $response, $args) 97 public function getLink($request, $response, $args)
99 { 98 {
100 if (!isset($this->linkDb[$args['id']])) { 99 if (!$this->bookmarkService->exists($args['id'])) {
101 throw new ApiLinkNotFoundException(); 100 throw new ApiLinkNotFoundException();
102 } 101 }
103 $index = index_url($this->ci['environment']); 102 $index = index_url($this->ci['environment']);
104 $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); 103 $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index);
105 104
106 return $response->withJson($out, 200, $this->jsonStyle); 105 return $response->withJson($out, 200, $this->jsonStyle);
107 } 106 }
@@ -117,9 +116,11 @@ class Links extends ApiController
117 public function postLink($request, $response) 116 public function postLink($request, $response)
118 { 117 {
119 $data = $request->getParsedBody(); 118 $data = $request->getParsedBody();
120 $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); 119 $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
121 // duplicate by URL, return 409 Conflict 120 // duplicate by URL, return 409 Conflict
122 if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { 121 if (! empty($bookmark->getUrl())
122 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
123 ) {
123 return $response->withJson( 124 return $response->withJson(
124 ApiUtils::formatLink($dup, index_url($this->ci['environment'])), 125 ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
125 409, 126 409,
@@ -127,23 +128,9 @@ class Links extends ApiController
127 ); 128 );
128 } 129 }
129 130
130 $link['id'] = $this->linkDb->getNextId(); 131 $this->bookmarkService->add($bookmark);
131 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 132 $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
132 133 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]);
133 // note: general relative URL
134 if (empty($link['url'])) {
135 $link['url'] = '?' . $link['shorturl'];
136 }
137
138 if (empty($link['title'])) {
139 $link['title'] = $link['url'];
140 }
141
142 $this->linkDb[$link['id']] = $link;
143 $this->linkDb->save($this->conf->get('resource.page_cache'));
144 $this->history->addLink($link);
145 $out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
146 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
147 return $response->withAddedHeader('Location', $redirect) 134 return $response->withAddedHeader('Location', $redirect)
148 ->withJson($out, 201, $this->jsonStyle); 135 ->withJson($out, 201, $this->jsonStyle);
149 } 136 }
@@ -161,18 +148,18 @@ class Links extends ApiController
161 */ 148 */
162 public function putLink($request, $response, $args) 149 public function putLink($request, $response, $args)
163 { 150 {
164 if (! isset($this->linkDb[$args['id']])) { 151 if (! $this->bookmarkService->exists($args['id'])) {
165 throw new ApiLinkNotFoundException(); 152 throw new ApiLinkNotFoundException();
166 } 153 }
167 154
168 $index = index_url($this->ci['environment']); 155 $index = index_url($this->ci['environment']);
169 $data = $request->getParsedBody(); 156 $data = $request->getParsedBody();
170 157
171 $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); 158 $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
172 // duplicate URL on a different link, return 409 Conflict 159 // duplicate URL on a different link, return 409 Conflict
173 if (! empty($requestLink['url']) 160 if (! empty($requestBookmark->getUrl())
174 && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) 161 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
175 && $dup['id'] != $args['id'] 162 && $dup->getId() != $args['id']
176 ) { 163 ) {
177 return $response->withJson( 164 return $response->withJson(
178 ApiUtils::formatLink($dup, $index), 165 ApiUtils::formatLink($dup, $index),
@@ -181,13 +168,11 @@ class Links extends ApiController
181 ); 168 );
182 } 169 }
183 170
184 $responseLink = $this->linkDb[$args['id']]; 171 $responseBookmark = $this->bookmarkService->get($args['id']);
185 $responseLink = ApiUtils::updateLink($responseLink, $requestLink); 172 $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
186 $this->linkDb[$responseLink['id']] = $responseLink; 173 $this->bookmarkService->set($responseBookmark);
187 $this->linkDb->save($this->conf->get('resource.page_cache'));
188 $this->history->updateLink($responseLink);
189 174
190 $out = ApiUtils::formatLink($responseLink, $index); 175 $out = ApiUtils::formatLink($responseBookmark, $index);
191 return $response->withJson($out, 200, $this->jsonStyle); 176 return $response->withJson($out, 200, $this->jsonStyle);
192 } 177 }
193 178
@@ -204,13 +189,11 @@ class Links extends ApiController
204 */ 189 */
205 public function deleteLink($request, $response, $args) 190 public function deleteLink($request, $response, $args)
206 { 191 {
207 if (! isset($this->linkDb[$args['id']])) { 192 if (! $this->bookmarkService->exists($args['id'])) {
208 throw new ApiLinkNotFoundException(); 193 throw new ApiLinkNotFoundException();
209 } 194 }
210 $link = $this->linkDb[$args['id']]; 195 $bookmark = $this->bookmarkService->get($args['id']);
211 unset($this->linkDb[(int) $args['id']]); 196 $this->bookmarkService->remove($bookmark);
212 $this->linkDb->save($this->conf->get('resource.page_cache'));
213 $this->history->deleteLink($link);
214 197
215 return $response->withStatus(204); 198 return $response->withStatus(204);
216 } 199 }
diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php
index 82f3ef74..e60e00a7 100644
--- a/application/api/controllers/Tags.php
+++ b/application/api/controllers/Tags.php
@@ -5,6 +5,7 @@ namespace Shaarli\Api\Controllers;
5use Shaarli\Api\ApiUtils; 5use Shaarli\Api\ApiUtils;
6use Shaarli\Api\Exceptions\ApiBadParametersException; 6use Shaarli\Api\Exceptions\ApiBadParametersException;
7use Shaarli\Api\Exceptions\ApiTagNotFoundException; 7use Shaarli\Api\Exceptions\ApiTagNotFoundException;
8use Shaarli\Bookmark\BookmarkFilter;
8use Slim\Http\Request; 9use Slim\Http\Request;
9use Slim\Http\Response; 10use Slim\Http\Response;
10 11
@@ -18,7 +19,7 @@ use Slim\Http\Response;
18class Tags extends ApiController 19class Tags extends ApiController
19{ 20{
20 /** 21 /**
21 * @var int Number of links returned if no limit is provided. 22 * @var int Number of bookmarks returned if no limit is provided.
22 */ 23 */
23 public static $DEFAULT_LIMIT = 'all'; 24 public static $DEFAULT_LIMIT = 'all';
24 25
@@ -35,7 +36,7 @@ class Tags extends ApiController
35 public function getTags($request, $response) 36 public function getTags($request, $response)
36 { 37 {
37 $visibility = $request->getParam('visibility'); 38 $visibility = $request->getParam('visibility');
38 $tags = $this->linkDb->linksCountPerTag([], $visibility); 39 $tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
39 40
40 // Return tags from the {offset}th tag, starting from 0. 41 // Return tags from the {offset}th tag, starting from 0.
41 $offset = $request->getParam('offset'); 42 $offset = $request->getParam('offset');
@@ -47,7 +48,7 @@ class Tags extends ApiController
47 return $response->withJson([], 200, $this->jsonStyle); 48 return $response->withJson([], 200, $this->jsonStyle);
48 } 49 }
49 50
50 // limit parameter is either a number of links or 'all' for everything. 51 // limit parameter is either a number of bookmarks or 'all' for everything.
51 $limit = $request->getParam('limit'); 52 $limit = $request->getParam('limit');
52 if (empty($limit)) { 53 if (empty($limit)) {
53 $limit = self::$DEFAULT_LIMIT; 54 $limit = self::$DEFAULT_LIMIT;
@@ -87,7 +88,7 @@ class Tags extends ApiController
87 */ 88 */
88 public function getTag($request, $response, $args) 89 public function getTag($request, $response, $args)
89 { 90 {
90 $tags = $this->linkDb->linksCountPerTag(); 91 $tags = $this->bookmarkService->bookmarksCountPerTag();
91 if (!isset($tags[$args['tagName']])) { 92 if (!isset($tags[$args['tagName']])) {
92 throw new ApiTagNotFoundException(); 93 throw new ApiTagNotFoundException();
93 } 94 }
@@ -111,7 +112,7 @@ class Tags extends ApiController
111 */ 112 */
112 public function putTag($request, $response, $args) 113 public function putTag($request, $response, $args)
113 { 114 {
114 $tags = $this->linkDb->linksCountPerTag(); 115 $tags = $this->bookmarkService->bookmarksCountPerTag();
115 if (! isset($tags[$args['tagName']])) { 116 if (! isset($tags[$args['tagName']])) {
116 throw new ApiTagNotFoundException(); 117 throw new ApiTagNotFoundException();
117 } 118 }
@@ -121,13 +122,19 @@ class Tags extends ApiController
121 throw new ApiBadParametersException('New tag name is required in the request body'); 122 throw new ApiBadParametersException('New tag name is required in the request body');
122 } 123 }
123 124
124 $updated = $this->linkDb->renameTag($args['tagName'], $data['name']); 125 $bookmarks = $this->bookmarkService->search(
125 $this->linkDb->save($this->conf->get('resource.page_cache')); 126 ['searchtags' => $args['tagName']],
126 foreach ($updated as $link) { 127 BookmarkFilter::$ALL,
127 $this->history->updateLink($link); 128 true
129 );
130 foreach ($bookmarks as $bookmark) {
131 $bookmark->renameTag($args['tagName'], $data['name']);
132 $this->bookmarkService->set($bookmark, false);
133 $this->history->updateLink($bookmark);
128 } 134 }
135 $this->bookmarkService->save();
129 136
130 $tags = $this->linkDb->linksCountPerTag(); 137 $tags = $this->bookmarkService->bookmarksCountPerTag();
131 $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]); 138 $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
132 return $response->withJson($out, 200, $this->jsonStyle); 139 return $response->withJson($out, 200, $this->jsonStyle);
133 } 140 }
@@ -145,15 +152,22 @@ class Tags extends ApiController
145 */ 152 */
146 public function deleteTag($request, $response, $args) 153 public function deleteTag($request, $response, $args)
147 { 154 {
148 $tags = $this->linkDb->linksCountPerTag(); 155 $tags = $this->bookmarkService->bookmarksCountPerTag();
149 if (! isset($tags[$args['tagName']])) { 156 if (! isset($tags[$args['tagName']])) {
150 throw new ApiTagNotFoundException(); 157 throw new ApiTagNotFoundException();
151 } 158 }
152 $updated = $this->linkDb->renameTag($args['tagName'], null); 159
153 $this->linkDb->save($this->conf->get('resource.page_cache')); 160 $bookmarks = $this->bookmarkService->search(
154 foreach ($updated as $link) { 161 ['searchtags' => $args['tagName']],
155 $this->history->updateLink($link); 162 BookmarkFilter::$ALL,
163 true
164 );
165 foreach ($bookmarks as $bookmark) {
166 $bookmark->deleteTag($args['tagName']);
167 $this->bookmarkService->set($bookmark, false);
168 $this->history->updateLink($bookmark);
156 } 169 }
170 $this->bookmarkService->save();
157 171
158 return $response->withStatus(204); 172 return $response->withStatus(204);
159 } 173 }
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
new file mode 100644
index 00000000..f9b21d3d
--- /dev/null
+++ b/application/bookmark/Bookmark.php
@@ -0,0 +1,461 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use DateTime;
6use Shaarli\Bookmark\Exception\InvalidBookmarkException;
7
8/**
9 * Class Bookmark
10 *
11 * This class represent a single Bookmark with all its attributes.
12 * Every bookmark should manipulated using this, before being formatted.
13 *
14 * @package Shaarli\Bookmark
15 */
16class Bookmark
17{
18 /** @var string Date format used in string (former ID format) */
19 const LINK_DATE_FORMAT = 'Ymd_His';
20
21 /** @var int Bookmark ID */
22 protected $id;
23
24 /** @var string Permalink identifier */
25 protected $shortUrl;
26
27 /** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
28 protected $url;
29
30 /** @var string Bookmark's title */
31 protected $title;
32
33 /** @var string Raw bookmark's description */
34 protected $description;
35
36 /** @var array List of bookmark's tags */
37 protected $tags;
38
39 /** @var string Thumbnail's URL - false if no thumbnail could be found */
40 protected $thumbnail;
41
42 /** @var bool Set to true if the bookmark is set as sticky */
43 protected $sticky;
44
45 /** @var DateTime Creation datetime */
46 protected $created;
47
48 /** @var DateTime Update datetime */
49 protected $updated;
50
51 /** @var bool True if the bookmark can only be seen while logged in */
52 protected $private;
53
54 /**
55 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
56 *
57 * @param array $data
58 *
59 * @return $this
60 */
61 public function fromArray($data)
62 {
63 $this->id = $data['id'];
64 $this->shortUrl = $data['shorturl'];
65 $this->url = $data['url'];
66 $this->title = $data['title'];
67 $this->description = $data['description'];
68 $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null;
69 $this->sticky = isset($data['sticky']) ? $data['sticky'] : false;
70 $this->created = $data['created'];
71 if (is_array($data['tags'])) {
72 $this->tags = $data['tags'];
73 } else {
74 $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY);
75 }
76 if (! empty($data['updated'])) {
77 $this->updated = $data['updated'];
78 }
79 $this->private = $data['private'] ? true : false;
80
81 return $this;
82 }
83
84 /**
85 * Make sure that the current instance of Bookmark is valid and can be saved into the data store.
86 * A valid link requires:
87 * - an integer ID
88 * - a short URL (for permalinks)
89 * - a creation date
90 *
91 * This function also initialize optional empty fields:
92 * - the URL with the permalink
93 * - the title with the URL
94 *
95 * @throws InvalidBookmarkException
96 */
97 public function validate()
98 {
99 if ($this->id === null
100 || ! is_int($this->id)
101 || empty($this->shortUrl)
102 || empty($this->created)
103 || ! $this->created instanceof DateTime
104 ) {
105 throw new InvalidBookmarkException($this);
106 }
107 if (empty($this->url)) {
108 $this->url = '?'. $this->shortUrl;
109 }
110 if (empty($this->title)) {
111 $this->title = $this->url;
112 }
113 }
114
115 /**
116 * Set the Id.
117 * If they're not already initialized, this function also set:
118 * - created: with the current datetime
119 * - shortUrl: with a generated small hash from the date and the given ID
120 *
121 * @param int $id
122 *
123 * @return Bookmark
124 */
125 public function setId($id)
126 {
127 $this->id = $id;
128 if (empty($this->created)) {
129 $this->created = new DateTime();
130 }
131 if (empty($this->shortUrl)) {
132 $this->shortUrl = link_small_hash($this->created, $this->id);
133 }
134
135 return $this;
136 }
137
138 /**
139 * Get the Id.
140 *
141 * @return int
142 */
143 public function getId()
144 {
145 return $this->id;
146 }
147
148 /**
149 * Get the ShortUrl.
150 *
151 * @return string
152 */
153 public function getShortUrl()
154 {
155 return $this->shortUrl;
156 }
157
158 /**
159 * Get the Url.
160 *
161 * @return string
162 */
163 public function getUrl()
164 {
165 return $this->url;
166 }
167
168 /**
169 * Get the Title.
170 *
171 * @return string
172 */
173 public function getTitle()
174 {
175 return $this->title;
176 }
177
178 /**
179 * Get the Description.
180 *
181 * @return string
182 */
183 public function getDescription()
184 {
185 return ! empty($this->description) ? $this->description : '';
186 }
187
188 /**
189 * Get the Created.
190 *
191 * @return DateTime
192 */
193 public function getCreated()
194 {
195 return $this->created;
196 }
197
198 /**
199 * Get the Updated.
200 *
201 * @return DateTime
202 */
203 public function getUpdated()
204 {
205 return $this->updated;
206 }
207
208 /**
209 * Set the ShortUrl.
210 *
211 * @param string $shortUrl
212 *
213 * @return Bookmark
214 */
215 public function setShortUrl($shortUrl)
216 {
217 $this->shortUrl = $shortUrl;
218
219 return $this;
220 }
221
222 /**
223 * Set the Url.
224 *
225 * @param string $url
226 * @param array $allowedProtocols
227 *
228 * @return Bookmark
229 */
230 public function setUrl($url, $allowedProtocols = [])
231 {
232 $url = trim($url);
233 if (! empty($url)) {
234 $url = whitelist_protocols($url, $allowedProtocols);
235 }
236 $this->url = $url;
237
238 return $this;
239 }
240
241 /**
242 * Set the Title.
243 *
244 * @param string $title
245 *
246 * @return Bookmark
247 */
248 public function setTitle($title)
249 {
250 $this->title = trim($title);
251
252 return $this;
253 }
254
255 /**
256 * Set the Description.
257 *
258 * @param string $description
259 *
260 * @return Bookmark
261 */
262 public function setDescription($description)
263 {
264 $this->description = $description;
265
266 return $this;
267 }
268
269 /**
270 * Set the Created.
271 * Note: you shouldn't set this manually except for special cases (like bookmark import)
272 *
273 * @param DateTime $created
274 *
275 * @return Bookmark
276 */
277 public function setCreated($created)
278 {
279 $this->created = $created;
280
281 return $this;
282 }
283
284 /**
285 * Set the Updated.
286 *
287 * @param DateTime $updated
288 *
289 * @return Bookmark
290 */
291 public function setUpdated($updated)
292 {
293 $this->updated = $updated;
294
295 return $this;
296 }
297
298 /**
299 * Get the Private.
300 *
301 * @return bool
302 */
303 public function isPrivate()
304 {
305 return $this->private ? true : false;
306 }
307
308 /**
309 * Set the Private.
310 *
311 * @param bool $private
312 *
313 * @return Bookmark
314 */
315 public function setPrivate($private)
316 {
317 $this->private = $private ? true : false;
318
319 return $this;
320 }
321
322 /**
323 * Get the Tags.
324 *
325 * @return array
326 */
327 public function getTags()
328 {
329 return is_array($this->tags) ? $this->tags : [];
330 }
331
332 /**
333 * Set the Tags.
334 *
335 * @param array $tags
336 *
337 * @return Bookmark
338 */
339 public function setTags($tags)
340 {
341 $this->setTagsString(implode(' ', $tags));
342
343 return $this;
344 }
345
346 /**
347 * Get the Thumbnail.
348 *
349 * @return string|bool
350 */
351 public function getThumbnail()
352 {
353 return !$this->isNote() ? $this->thumbnail : false;
354 }
355
356 /**
357 * Set the Thumbnail.
358 *
359 * @param string|bool $thumbnail
360 *
361 * @return Bookmark
362 */
363 public function setThumbnail($thumbnail)
364 {
365 $this->thumbnail = $thumbnail;
366
367 return $this;
368 }
369
370 /**
371 * Get the Sticky.
372 *
373 * @return bool
374 */
375 public function isSticky()
376 {
377 return $this->sticky ? true : false;
378 }
379
380 /**
381 * Set the Sticky.
382 *
383 * @param bool $sticky
384 *
385 * @return Bookmark
386 */
387 public function setSticky($sticky)
388 {
389 $this->sticky = $sticky ? true : false;
390
391 return $this;
392 }
393
394 /**
395 * @return string Bookmark's tags as a string, separated by a space
396 */
397 public function getTagsString()
398 {
399 return implode(' ', $this->getTags());
400 }
401
402 /**
403 * @return bool
404 */
405 public function isNote()
406 {
407 // We check empty value to get a valid result if the link has not been saved yet
408 return empty($this->url) || $this->url[0] === '?';
409 }
410
411 /**
412 * Set tags from a string.
413 * Note:
414 * - tags must be separated whether by a space or a comma
415 * - multiple spaces will be removed
416 * - trailing dash in tags will be removed
417 *
418 * @param string $tags
419 *
420 * @return $this
421 */
422 public function setTagsString($tags)
423 {
424 // Remove first '-' char in tags.
425 $tags = preg_replace('/(^| )\-/', '$1', $tags);
426 // Explode all tags separted by spaces or commas
427 $tags = preg_split('/[\s,]+/', $tags);
428 // Remove eventual empty values
429 $tags = array_values(array_filter($tags));
430
431 $this->tags = $tags;
432
433 return $this;
434 }
435
436 /**
437 * Rename a tag in tags list.
438 *
439 * @param string $fromTag
440 * @param string $toTag
441 */
442 public function renameTag($fromTag, $toTag)
443 {
444 if (($pos = array_search($fromTag, $this->tags)) !== false) {
445 $this->tags[$pos] = trim($toTag);
446 }
447 }
448
449 /**
450 * Delete a tag from tags list.
451 *
452 * @param string $tag
453 */
454 public function deleteTag($tag)
455 {
456 if (($pos = array_search($tag, $this->tags)) !== false) {
457 unset($this->tags[$pos]);
458 $this->tags = array_values($this->tags);
459 }
460 }
461}
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
new file mode 100644
index 00000000..d87d43b4
--- /dev/null
+++ b/application/bookmark/BookmarkArray.php
@@ -0,0 +1,259 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Shaarli\Bookmark\Exception\InvalidBookmarkException;
6
7/**
8 * Class BookmarkArray
9 *
10 * Implementing ArrayAccess, this allows us to use the bookmark list
11 * as an array and iterate over it.
12 *
13 * @package Shaarli\Bookmark
14 */
15class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
16{
17 /**
18 * @var Bookmark[]
19 */
20 protected $bookmarks;
21
22 /**
23 * @var array List of all bookmarks IDS mapped with their array offset.
24 * Map: id->offset.
25 */
26 protected $ids;
27
28 /**
29 * @var int Position in the $this->keys array (for the Iterator interface)
30 */
31 protected $position;
32
33 /**
34 * @var array List of offset keys (for the Iterator interface implementation)
35 */
36 protected $keys;
37
38 /**
39 * @var array List of all recorded URLs (key=url, value=bookmark offset)
40 * for fast reserve search (url-->bookmark offset)
41 */
42 protected $urls;
43
44 public function __construct()
45 {
46 $this->ids = [];
47 $this->bookmarks = [];
48 $this->keys = [];
49 $this->urls = [];
50 $this->position = 0;
51 }
52
53 /**
54 * Countable - Counts elements of an object
55 *
56 * @return int Number of bookmarks
57 */
58 public function count()
59 {
60 return count($this->bookmarks);
61 }
62
63 /**
64 * ArrayAccess - Assigns a value to the specified offset
65 *
66 * @param int $offset Bookmark ID
67 * @param Bookmark $value instance
68 *
69 * @throws InvalidBookmarkException
70 */
71 public function offsetSet($offset, $value)
72 {
73 if (! $value instanceof Bookmark
74 || $value->getId() === null || empty($value->getUrl())
75 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
76 || $offset !== null && $offset !== $value->getId()
77 ) {
78 throw new InvalidBookmarkException($value);
79 }
80
81 // If the bookmark exists, we reuse the real offset, otherwise new entry
82 if ($offset !== null) {
83 $existing = $this->getBookmarkOffset($offset);
84 } else {
85 $existing = $this->getBookmarkOffset($value->getId());
86 }
87
88 if ($existing !== null) {
89 $offset = $existing;
90 } else {
91 $offset = count($this->bookmarks);
92 }
93
94 $this->bookmarks[$offset] = $value;
95 $this->urls[$value->getUrl()] = $offset;
96 $this->ids[$value->getId()] = $offset;
97 }
98
99 /**
100 * ArrayAccess - Whether or not an offset exists
101 *
102 * @param int $offset Bookmark ID
103 *
104 * @return bool true if it exists, false otherwise
105 */
106 public function offsetExists($offset)
107 {
108 return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
109 }
110
111 /**
112 * ArrayAccess - Unsets an offset
113 *
114 * @param int $offset Bookmark ID
115 */
116 public function offsetUnset($offset)
117 {
118 $realOffset = $this->getBookmarkOffset($offset);
119 $url = $this->bookmarks[$realOffset]->getUrl();
120 unset($this->urls[$url]);
121 unset($this->ids[$offset]);
122 unset($this->bookmarks[$realOffset]);
123 }
124
125 /**
126 * ArrayAccess - Returns the value at specified offset
127 *
128 * @param int $offset Bookmark ID
129 *
130 * @return Bookmark|null The Bookmark if found, null otherwise
131 */
132 public function offsetGet($offset)
133 {
134 $realOffset = $this->getBookmarkOffset($offset);
135 return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
136 }
137
138 /**
139 * Iterator - Returns the current element
140 *
141 * @return Bookmark corresponding to the current position
142 */
143 public function current()
144 {
145 return $this[$this->keys[$this->position]];
146 }
147
148 /**
149 * Iterator - Returns the key of the current element
150 *
151 * @return int Bookmark ID corresponding to the current position
152 */
153 public function key()
154 {
155 return $this->keys[$this->position];
156 }
157
158 /**
159 * Iterator - Moves forward to next element
160 */
161 public function next()
162 {
163 ++$this->position;
164 }
165
166 /**
167 * Iterator - Rewinds the Iterator to the first element
168 *
169 * Entries are sorted by date (latest first)
170 */
171 public function rewind()
172 {
173 $this->keys = array_keys($this->ids);
174 $this->position = 0;
175 }
176
177 /**
178 * Iterator - Checks if current position is valid
179 *
180 * @return bool true if the current Bookmark ID exists, false otherwise
181 */
182 public function valid()
183 {
184 return isset($this->keys[$this->position]);
185 }
186
187 /**
188 * Returns a bookmark offset in bookmarks array from its unique ID.
189 *
190 * @param int $id Persistent ID of a bookmark.
191 *
192 * @return int Real offset in local array, or null if doesn't exist.
193 */
194 protected function getBookmarkOffset($id)
195 {
196 if (isset($this->ids[$id])) {
197 return $this->ids[$id];
198 }
199 return null;
200 }
201
202 /**
203 * Return the next key for bookmark creation.
204 * E.g. If the last ID is 597, the next will be 598.
205 *
206 * @return int next ID.
207 */
208 public function getNextId()
209 {
210 if (!empty($this->ids)) {
211 return max(array_keys($this->ids)) + 1;
212 }
213 return 0;
214 }
215
216 /**
217 * @param $url
218 *
219 * @return Bookmark|null
220 */
221 public function getByUrl($url)
222 {
223 if (! empty($url)
224 && isset($this->urls[$url])
225 && isset($this->bookmarks[$this->urls[$url]])
226 ) {
227 return $this->bookmarks[$this->urls[$url]];
228 }
229 return null;
230 }
231
232 /**
233 * Reorder links by creation date (newest first).
234 *
235 * Also update the urls and ids mapping arrays.
236 *
237 * @param string $order ASC|DESC
238 */
239 public function reorder($order = 'DESC')
240 {
241 $order = $order === 'ASC' ? -1 : 1;
242 // Reorder array by dates.
243 usort($this->bookmarks, function ($a, $b) use ($order) {
244 /** @var $a Bookmark */
245 /** @var $b Bookmark */
246 if ($a->isSticky() !== $b->isSticky()) {
247 return $a->isSticky() ? -1 : 1;
248 }
249 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
250 });
251
252 $this->urls = [];
253 $this->ids = [];
254 foreach ($this->bookmarks as $key => $bookmark) {
255 $this->urls[$bookmark->getUrl()] = $key;
256 $this->ids[$bookmark->getId()] = $key;
257 }
258 }
259}
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
new file mode 100644
index 00000000..a56cc92b
--- /dev/null
+++ b/application/bookmark/BookmarkFileService.php
@@ -0,0 +1,373 @@
1<?php
2
3
4namespace Shaarli\Bookmark;
5
6
7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Bookmark\Exception\EmptyDataStoreException;
10use Shaarli\Config\ConfigManager;
11use Shaarli\History;
12use Shaarli\Legacy\LegacyLinkDB;
13use Shaarli\Legacy\LegacyUpdater;
14use Shaarli\Updater\UpdaterUtils;
15
16/**
17 * Class BookmarksService
18 *
19 * This is the entry point to manipulate the bookmark DB.
20 * It manipulates loads links from a file data store containing all bookmarks.
21 *
22 * It also triggers the legacy format (bookmarks as arrays) migration.
23 */
24class BookmarkFileService implements BookmarkServiceInterface
25{
26 /** @var Bookmark[] instance */
27 protected $bookmarks;
28
29 /** @var BookmarkIO instance */
30 protected $bookmarksIO;
31
32 /** @var BookmarkFilter */
33 protected $bookmarkFilter;
34
35 /** @var ConfigManager instance */
36 protected $conf;
37
38 /** @var History instance */
39 protected $history;
40
41 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
42 protected $isLoggedIn;
43
44 /**
45 * @inheritDoc
46 */
47 public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
48 {
49 $this->conf = $conf;
50 $this->history = $history;
51 $this->bookmarksIO = new BookmarkIO($this->conf);
52 $this->isLoggedIn = $isLoggedIn;
53
54 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
55 $this->bookmarks = [];
56 } else {
57 try {
58 $this->bookmarks = $this->bookmarksIO->read();
59 } catch (EmptyDataStoreException $e) {
60 $this->bookmarks = new BookmarkArray();
61 if ($isLoggedIn) {
62 $this->save();
63 }
64 }
65
66 if (! $this->bookmarks instanceof BookmarkArray) {
67 $this->migrate();
68 exit(
69 'Your data store has been migrated, please reload the page.'. PHP_EOL .
70 'If this message keeps showing up, please delete data/updates.txt file.'
71 );
72 }
73 }
74
75 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
76 }
77
78 /**
79 * @inheritDoc
80 */
81 public function findByHash($hash)
82 {
83 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
84 // PHP 7.3 introduced array_key_first() to avoid this hack
85 $first = reset($bookmark);
86 if (! $this->isLoggedIn && $first->isPrivate()) {
87 throw new Exception('Not authorized');
88 }
89
90 return $bookmark;
91 }
92
93 /**
94 * @inheritDoc
95 */
96 public function findByUrl($url)
97 {
98 return $this->bookmarks->getByUrl($url);
99 }
100
101 /**
102 * @inheritDoc
103 */
104 public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false)
105 {
106 if ($visibility === null) {
107 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
108 }
109
110 // Filter bookmark database according to parameters.
111 $searchtags = isset($request['searchtags']) ? $request['searchtags'] : '';
112 $searchterm = isset($request['searchterm']) ? $request['searchterm'] : '';
113
114 return $this->bookmarkFilter->filter(
115 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
116 [$searchtags, $searchterm],
117 $caseSensitive,
118 $visibility,
119 $untaggedOnly
120 );
121 }
122
123 /**
124 * @inheritDoc
125 */
126 public function get($id, $visibility = null)
127 {
128 if (! isset($this->bookmarks[$id])) {
129 throw new BookmarkNotFoundException();
130 }
131
132 if ($visibility === null) {
133 $visibility = $this->isLoggedIn ? 'all' : 'public';
134 }
135
136 $bookmark = $this->bookmarks[$id];
137 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
138 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
139 ) {
140 throw new Exception('Unauthorized');
141 }
142
143 return $bookmark;
144 }
145
146 /**
147 * @inheritDoc
148 */
149 public function set($bookmark, $save = true)
150 {
151 if ($this->isLoggedIn !== true) {
152 throw new Exception(t('You\'re not authorized to alter the datastore'));
153 }
154 if (! $bookmark instanceof Bookmark) {
155 throw new Exception(t('Provided data is invalid'));
156 }
157 if (! isset($this->bookmarks[$bookmark->getId()])) {
158 throw new BookmarkNotFoundException();
159 }
160 $bookmark->validate();
161
162 $bookmark->setUpdated(new \DateTime());
163 $this->bookmarks[$bookmark->getId()] = $bookmark;
164 if ($save === true) {
165 $this->save();
166 $this->history->updateLink($bookmark);
167 }
168 return $this->bookmarks[$bookmark->getId()];
169 }
170
171 /**
172 * @inheritDoc
173 */
174 public function add($bookmark, $save = true)
175 {
176 if ($this->isLoggedIn !== true) {
177 throw new Exception(t('You\'re not authorized to alter the datastore'));
178 }
179 if (! $bookmark instanceof Bookmark) {
180 throw new Exception(t('Provided data is invalid'));
181 }
182 if (! empty($bookmark->getId())) {
183 throw new Exception(t('This bookmarks already exists'));
184 }
185 $bookmark->setId($this->bookmarks->getNextId());
186 $bookmark->validate();
187
188 $this->bookmarks[$bookmark->getId()] = $bookmark;
189 if ($save === true) {
190 $this->save();
191 $this->history->addLink($bookmark);
192 }
193 return $this->bookmarks[$bookmark->getId()];
194 }
195
196 /**
197 * @inheritDoc
198 */
199 public function addOrSet($bookmark, $save = true)
200 {
201 if ($this->isLoggedIn !== true) {
202 throw new Exception(t('You\'re not authorized to alter the datastore'));
203 }
204 if (! $bookmark instanceof Bookmark) {
205 throw new Exception('Provided data is invalid');
206 }
207 if ($bookmark->getId() === null) {
208 return $this->add($bookmark, $save);
209 }
210 return $this->set($bookmark, $save);
211 }
212
213 /**
214 * @inheritDoc
215 */
216 public function remove($bookmark, $save = true)
217 {
218 if ($this->isLoggedIn !== true) {
219 throw new Exception(t('You\'re not authorized to alter the datastore'));
220 }
221 if (! $bookmark instanceof Bookmark) {
222 throw new Exception(t('Provided data is invalid'));
223 }
224 if (! isset($this->bookmarks[$bookmark->getId()])) {
225 throw new BookmarkNotFoundException();
226 }
227
228 unset($this->bookmarks[$bookmark->getId()]);
229 if ($save === true) {
230 $this->save();
231 $this->history->deleteLink($bookmark);
232 }
233 }
234
235 /**
236 * @inheritDoc
237 */
238 public function exists($id, $visibility = null)
239 {
240 if (! isset($this->bookmarks[$id])) {
241 return false;
242 }
243
244 if ($visibility === null) {
245 $visibility = $this->isLoggedIn ? 'all' : 'public';
246 }
247
248 $bookmark = $this->bookmarks[$id];
249 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
250 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
251 ) {
252 return false;
253 }
254
255 return true;
256 }
257
258 /**
259 * @inheritDoc
260 */
261 public function count($visibility = null)
262 {
263 return count($this->search([], $visibility));
264 }
265
266 /**
267 * @inheritDoc
268 */
269 public function save()
270 {
271 if (!$this->isLoggedIn) {
272 // TODO: raise an Exception instead
273 die('You are not authorized to change the database.');
274 }
275 $this->bookmarks->reorder();
276 $this->bookmarksIO->write($this->bookmarks);
277 invalidateCaches($this->conf->get('resource.page_cache'));
278 }
279
280 /**
281 * @inheritDoc
282 */
283 public function bookmarksCountPerTag($filteringTags = [], $visibility = null)
284 {
285 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
286 $tags = [];
287 $caseMapping = [];
288 foreach ($bookmarks as $bookmark) {
289 foreach ($bookmark->getTags() as $tag) {
290 if (empty($tag) || (! $this->isLoggedIn && startsWith($tag, '.'))) {
291 continue;
292 }
293 // The first case found will be displayed.
294 if (!isset($caseMapping[strtolower($tag)])) {
295 $caseMapping[strtolower($tag)] = $tag;
296 $tags[$caseMapping[strtolower($tag)]] = 0;
297 }
298 $tags[$caseMapping[strtolower($tag)]]++;
299 }
300 }
301
302 /*
303 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
304 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
305 *
306 * So we now use array_multisort() to sort tags by DESC occurrences,
307 * then ASC alphabetically for equal values.
308 *
309 * @see https://github.com/shaarli/Shaarli/issues/1142
310 */
311 $keys = array_keys($tags);
312 $tmpTags = array_combine($keys, $keys);
313 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
314 return $tags;
315 }
316
317 /**
318 * @inheritDoc
319 */
320 public function days()
321 {
322 $bookmarkDays = [];
323 foreach ($this->search() as $bookmark) {
324 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0;
325 }
326 $bookmarkDays = array_keys($bookmarkDays);
327 sort($bookmarkDays);
328
329 return $bookmarkDays;
330 }
331
332 /**
333 * @inheritDoc
334 */
335 public function filterDay($request)
336 {
337 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request);
338 }
339
340 /**
341 * @inheritDoc
342 */
343 public function initialize()
344 {
345 $initializer = new BookmarkInitializer($this);
346 $initializer->initialize();
347 }
348
349 /**
350 * Handles migration to the new database format (BookmarksArray).
351 */
352 protected function migrate()
353 {
354 $bookmarkDb = new LegacyLinkDB(
355 $this->conf->get('resource.datastore'),
356 true,
357 false
358 );
359 $updater = new LegacyUpdater(
360 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')),
361 $bookmarkDb,
362 $this->conf,
363 true
364 );
365 $newUpdates = $updater->update();
366 if (! empty($newUpdates)) {
367 UpdaterUtils::write_updates_file(
368 $this->conf->get('resource.updates'),
369 $updater->getDoneUpdates()
370 );
371 }
372 }
373}
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
new file mode 100644
index 00000000..fd556679
--- /dev/null
+++ b/application/bookmark/BookmarkFilter.php
@@ -0,0 +1,468 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Exception;
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7
8/**
9 * Class LinkFilter.
10 *
11 * Perform search and filter operation on link data list.
12 */
13class BookmarkFilter
14{
15 /**
16 * @var string permalinks.
17 */
18 public static $FILTER_HASH = 'permalink';
19
20 /**
21 * @var string text search.
22 */
23 public static $FILTER_TEXT = 'fulltext';
24
25 /**
26 * @var string tag filter.
27 */
28 public static $FILTER_TAG = 'tags';
29
30 /**
31 * @var string filter by day.
32 */
33 public static $FILTER_DAY = 'FILTER_DAY';
34
35 /**
36 * @var string filter by day.
37 */
38 public static $DEFAULT = 'NO_FILTER';
39
40 /** @var string Visibility: all */
41 public static $ALL = 'all';
42
43 /** @var string Visibility: public */
44 public static $PUBLIC = 'public';
45
46 /** @var string Visibility: private */
47 public static $PRIVATE = 'private';
48
49 /**
50 * @var string Allowed characters for hashtags (regex syntax).
51 */
52 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
53
54 /**
55 * @var Bookmark[] all available bookmarks.
56 */
57 private $bookmarks;
58
59 /**
60 * @param Bookmark[] $bookmarks initialization.
61 */
62 public function __construct($bookmarks)
63 {
64 $this->bookmarks = $bookmarks;
65 }
66
67 /**
68 * Filter bookmarks according to parameters.
69 *
70 * @param string $type Type of filter (eg. tags, permalink, etc.).
71 * @param mixed $request Filter content.
72 * @param bool $casesensitive Optional: Perform case sensitive filter if true.
73 * @param string $visibility Optional: return only all/private/public bookmarks
74 * @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
75 *
76 * @return Bookmark[] filtered bookmark list.
77 *
78 * @throws BookmarkNotFoundException
79 */
80 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
81 {
82 if (!in_array($visibility, ['all', 'public', 'private'])) {
83 $visibility = 'all';
84 }
85
86 switch ($type) {
87 case self::$FILTER_HASH:
88 return $this->filterSmallHash($request);
89 case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
90 $noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
91 if ($noRequest) {
92 if ($untaggedonly) {
93 return $this->filterUntagged($visibility);
94 }
95 return $this->noFilter($visibility);
96 }
97 if ($untaggedonly) {
98 $filtered = $this->filterUntagged($visibility);
99 } else {
100 $filtered = $this->bookmarks;
101 }
102 if (!empty($request[0])) {
103 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
104 }
105 if (!empty($request[1])) {
106 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
107 }
108 return $filtered;
109 case self::$FILTER_TEXT:
110 return $this->filterFulltext($request, $visibility);
111 case self::$FILTER_TAG:
112 if ($untaggedonly) {
113 return $this->filterUntagged($visibility);
114 } else {
115 return $this->filterTags($request, $casesensitive, $visibility);
116 }
117 case self::$FILTER_DAY:
118 return $this->filterDay($request);
119 default:
120 return $this->noFilter($visibility);
121 }
122 }
123
124 /**
125 * Unknown filter, but handle private only.
126 *
127 * @param string $visibility Optional: return only all/private/public bookmarks
128 *
129 * @return Bookmark[] filtered bookmarks.
130 */
131 private function noFilter($visibility = 'all')
132 {
133 if ($visibility === 'all') {
134 return $this->bookmarks;
135 }
136
137 $out = array();
138 foreach ($this->bookmarks as $key => $value) {
139 if ($value->isPrivate() && $visibility === 'private') {
140 $out[$key] = $value;
141 } elseif (!$value->isPrivate() && $visibility === 'public') {
142 $out[$key] = $value;
143 }
144 }
145
146 return $out;
147 }
148
149 /**
150 * Returns the shaare corresponding to a smallHash.
151 *
152 * @param string $smallHash permalink hash.
153 *
154 * @return array $filtered array containing permalink data.
155 *
156 * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link.
157 */
158 private function filterSmallHash($smallHash)
159 {
160 foreach ($this->bookmarks as $key => $l) {
161 if ($smallHash == $l->getShortUrl()) {
162 // Yes, this is ugly and slow
163 return [$key => $l];
164 }
165 }
166
167 throw new BookmarkNotFoundException();
168 }
169
170 /**
171 * Returns the list of bookmarks corresponding to a full-text search
172 *
173 * Searches:
174 * - in the URLs, title and description;
175 * - are case-insensitive;
176 * - terms surrounded by quotes " are exact terms search.
177 * - terms starting with a dash - are excluded (except exact terms).
178 *
179 * Example:
180 * print_r($mydb->filterFulltext('hollandais'));
181 *
182 * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
183 * - allows to perform searches on Unicode text
184 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
185 *
186 * @param string $searchterms search query.
187 * @param string $visibility Optional: return only all/private/public bookmarks.
188 *
189 * @return array search results.
190 */
191 private function filterFulltext($searchterms, $visibility = 'all')
192 {
193 if (empty($searchterms)) {
194 return $this->noFilter($visibility);
195 }
196
197 $filtered = array();
198 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
199 $exactRegex = '/"([^"]+)"/';
200 // Retrieve exact search terms.
201 preg_match_all($exactRegex, $search, $exactSearch);
202 $exactSearch = array_values(array_filter($exactSearch[1]));
203
204 // Remove exact search terms to get AND terms search.
205 $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
206 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
207
208 // Filter excluding terms and update andSearch.
209 $excludeSearch = array();
210 $andSearch = array();
211 foreach ($explodedSearchAnd as $needle) {
212 if ($needle[0] == '-' && strlen($needle) > 1) {
213 $excludeSearch[] = substr($needle, 1);
214 } else {
215 $andSearch[] = $needle;
216 }
217 }
218
219 // Iterate over every stored link.
220 foreach ($this->bookmarks as $id => $link) {
221 // ignore non private bookmarks when 'privatonly' is on.
222 if ($visibility !== 'all') {
223 if (!$link->isPrivate() && $visibility === 'private') {
224 continue;
225 } elseif ($link->isPrivate() && $visibility === 'public') {
226 continue;
227 }
228 }
229
230 // Concatenate link fields to search across fields.
231 // Adds a '\' separator for exact search terms.
232 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
233 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
234 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
235 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
236
237 // Be optimistic
238 $found = true;
239
240 // First, we look for exact term search
241 for ($i = 0; $i < count($exactSearch) && $found; $i++) {
242 $found = strpos($content, $exactSearch[$i]) !== false;
243 }
244
245 // Iterate over keywords, if keyword is not found,
246 // no need to check for the others. We want all or nothing.
247 for ($i = 0; $i < count($andSearch) && $found; $i++) {
248 $found = strpos($content, $andSearch[$i]) !== false;
249 }
250
251 // Exclude terms.
252 for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
253 $found = strpos($content, $excludeSearch[$i]) === false;
254 }
255
256 if ($found) {
257 $filtered[$id] = $link;
258 }
259 }
260
261 return $filtered;
262 }
263
264 /**
265 * generate a regex fragment out of a tag
266 *
267 * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
268 *
269 * @return string generated regex fragment
270 */
271 private static function tag2regex($tag)
272 {
273 $len = strlen($tag);
274 if (!$len || $tag === "-" || $tag === "*") {
275 // nothing to search, return empty regex
276 return '';
277 }
278 if ($tag[0] === "-") {
279 // query is negated
280 $i = 1; // use offset to start after '-' character
281 $regex = '(?!'; // create negative lookahead
282 } else {
283 $i = 0; // start at first character
284 $regex = '(?='; // use positive lookahead
285 }
286 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
287 // iterate over string, separating it into placeholder and content
288 for (; $i < $len; $i++) {
289 if ($tag[$i] === '*') {
290 // placeholder found
291 $regex .= '[^ ]*?';
292 } else {
293 // regular characters
294 $offset = strpos($tag, '*', $i);
295 if ($offset === false) {
296 // no placeholder found, set offset to end of string
297 $offset = $len;
298 }
299 // subtract one, as we want to get before the placeholder or end of string
300 $offset -= 1;
301 // we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
302 $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
303 // move $i on
304 $i = $offset;
305 }
306 }
307 $regex .= '(?:$| ))'; // after the tag may only be a space or the end
308 return $regex;
309 }
310
311 /**
312 * Returns the list of bookmarks associated with a given list of tags
313 *
314 * You can specify one or more tags, separated by space or a comma, e.g.
315 * print_r($mydb->filterTags('linux programming'));
316 *
317 * @param string $tags list of tags separated by commas or blank spaces.
318 * @param bool $casesensitive ignore case if false.
319 * @param string $visibility Optional: return only all/private/public bookmarks.
320 *
321 * @return array filtered bookmarks.
322 */
323 public function filterTags($tags, $casesensitive = false, $visibility = 'all')
324 {
325 // get single tags (we may get passed an array, even though the docs say different)
326 $inputTags = $tags;
327 if (!is_array($tags)) {
328 // we got an input string, split tags
329 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
330 }
331
332 if (!count($inputTags)) {
333 // no input tags
334 return $this->noFilter($visibility);
335 }
336
337 // If we only have public visibility, we can't look for hidden tags
338 if ($visibility === self::$PUBLIC) {
339 $inputTags = array_values(array_filter($inputTags, function ($tag) {
340 return ! startsWith($tag, '.');
341 }));
342
343 if (empty($inputTags)) {
344 return [];
345 }
346 }
347
348 // build regex from all tags
349 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
350 if (!$casesensitive) {
351 // make regex case insensitive
352 $re .= 'i';
353 }
354
355 // create resulting array
356 $filtered = [];
357
358 // iterate over each link
359 foreach ($this->bookmarks as $key => $link) {
360 // check level of visibility
361 // ignore non private bookmarks when 'privateonly' is on.
362 if ($visibility !== 'all') {
363 if (!$link->isPrivate() && $visibility === 'private') {
364 continue;
365 } elseif ($link->isPrivate() && $visibility === 'public') {
366 continue;
367 }
368 }
369 $search = $link->getTagsString(); // build search string, start with tags of current link
370 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
371 // description given and at least one possible tag found
372 $descTags = array();
373 // find all tags in the form of #tag in the description
374 preg_match_all(
375 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
376 $link->getDescription(),
377 $descTags
378 );
379 if (count($descTags[1])) {
380 // there were some tags in the description, add them to the search string
381 $search .= ' ' . implode(' ', $descTags[1]);
382 }
383 };
384 // match regular expression with search string
385 if (!preg_match($re, $search)) {
386 // this entry does _not_ match our regex
387 continue;
388 }
389 $filtered[$key] = $link;
390 }
391 return $filtered;
392 }
393
394 /**
395 * Return only bookmarks without any tag.
396 *
397 * @param string $visibility return only all/private/public bookmarks.
398 *
399 * @return array filtered bookmarks.
400 */
401 public function filterUntagged($visibility)
402 {
403 $filtered = [];
404 foreach ($this->bookmarks as $key => $link) {
405 if ($visibility !== 'all') {
406 if (!$link->isPrivate() && $visibility === 'private') {
407 continue;
408 } elseif ($link->isPrivate() && $visibility === 'public') {
409 continue;
410 }
411 }
412
413 if (empty(trim($link->getTagsString()))) {
414 $filtered[$key] = $link;
415 }
416 }
417
418 return $filtered;
419 }
420
421 /**
422 * Returns the list of articles for a given day, chronologically sorted
423 *
424 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
425 * print_r($mydb->filterDay('20120125'));
426 *
427 * @param string $day day to filter.
428 *
429 * @return array all link matching given day.
430 *
431 * @throws Exception if date format is invalid.
432 */
433 public function filterDay($day)
434 {
435 if (!checkDateFormat('Ymd', $day)) {
436 throw new Exception('Invalid date format');
437 }
438
439 $filtered = array();
440 foreach ($this->bookmarks as $key => $l) {
441 if ($l->getCreated()->format('Ymd') == $day) {
442 $filtered[$key] = $l;
443 }
444 }
445
446 // sort by date ASC
447 return array_reverse($filtered, true);
448 }
449
450 /**
451 * Convert a list of tags (str) to an array. Also
452 * - handle case sensitivity.
453 * - accepts spaces commas as separator.
454 *
455 * @param string $tags string containing a list of tags.
456 * @param bool $casesensitive will convert everything to lowercase if false.
457 *
458 * @return array filtered tags string.
459 */
460 public static function tagsStrToArray($tags, $casesensitive)
461 {
462 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
463 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
464 $tagsOut = str_replace(',', ' ', $tagsOut);
465
466 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
467 }
468}
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
new file mode 100644
index 00000000..ae9ffcb4
--- /dev/null
+++ b/application/bookmark/BookmarkIO.php
@@ -0,0 +1,108 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Shaarli\Bookmark\Exception\EmptyDataStoreException;
6use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
7use Shaarli\Config\ConfigManager;
8
9/**
10 * Class BookmarkIO
11 *
12 * This class performs read/write operation to the file data store.
13 * Used by BookmarkFileService.
14 *
15 * @package Shaarli\Bookmark
16 */
17class BookmarkIO
18{
19 /**
20 * @var string Datastore file path
21 */
22 protected $datastore;
23
24 /**
25 * @var ConfigManager instance
26 */
27 protected $conf;
28
29 /**
30 * string Datastore PHP prefix
31 */
32 protected static $phpPrefix = '<?php /* ';
33
34 /**
35 * string Datastore PHP suffix
36 */
37 protected static $phpSuffix = ' */ ?>';
38
39 /**
40 * LinksIO constructor.
41 *
42 * @param ConfigManager $conf instance
43 */
44 public function __construct($conf)
45 {
46 $this->conf = $conf;
47 $this->datastore = $conf->get('resource.datastore');
48 }
49
50 /**
51 * Reads database from disk to memory
52 *
53 * @return BookmarkArray instance
54 *
55 * @throws NotWritableDataStoreException Data couldn't be loaded
56 * @throws EmptyDataStoreException Datastore doesn't exist
57 */
58 public function read()
59 {
60 if (! file_exists($this->datastore)) {
61 throw new EmptyDataStoreException();
62 }
63
64 if (!is_writable($this->datastore)) {
65 throw new NotWritableDataStoreException($this->datastore);
66 }
67
68 // Note that gzinflate is faster than gzuncompress.
69 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
70 $links = unserialize(gzinflate(base64_decode(
71 substr(file_get_contents($this->datastore),
72 strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
73
74 if (empty($links)) {
75 if (filesize($this->datastore) > 100) {
76 throw new NotWritableDataStoreException($this->datastore);
77 }
78 throw new EmptyDataStoreException();
79 }
80
81 return $links;
82 }
83
84 /**
85 * Saves the database from memory to disk
86 *
87 * @param BookmarkArray $links instance.
88 *
89 * @throws NotWritableDataStoreException the datastore is not writable
90 */
91 public function write($links)
92 {
93 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
94 // The datastore exists but is not writeable
95 throw new NotWritableDataStoreException($this->datastore);
96 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
97 // The datastore does not exist and its parent directory is not writeable
98 throw new NotWritableDataStoreException(dirname($this->datastore));
99 }
100
101 file_put_contents(
102 $this->datastore,
103 self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
104 );
105
106 invalidateCaches($this->conf->get('resource.page_cache'));
107 }
108}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
new file mode 100644
index 00000000..9eee9a35
--- /dev/null
+++ b/application/bookmark/BookmarkInitializer.php
@@ -0,0 +1,59 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5/**
6 * Class BookmarkInitializer
7 *
8 * This class is used to initialized default bookmarks after a fresh install of Shaarli.
9 * It is no longer call when the data store is empty,
10 * because user might want to delete default bookmarks after the install.
11 *
12 * To prevent data corruption, it does not overwrite existing bookmarks,
13 * even though there should not be any.
14 *
15 * @package Shaarli\Bookmark
16 */
17class BookmarkInitializer
18{
19 /** @var BookmarkServiceInterface */
20 protected $bookmarkService;
21
22 /**
23 * BookmarkInitializer constructor.
24 *
25 * @param BookmarkServiceInterface $bookmarkService
26 */
27 public function __construct($bookmarkService)
28 {
29 $this->bookmarkService = $bookmarkService;
30 }
31
32 /**
33 * Initialize the data store with default bookmarks
34 */
35 public function initialize()
36 {
37 $bookmark = new Bookmark();
38 $bookmark->setTitle(t('My secret stuff... - Pastebin.com'));
39 $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []);
40 $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'));
41 $bookmark->setTagsString('secretstuff');
42 $bookmark->setPrivate(true);
43 $this->bookmarkService->add($bookmark);
44
45 $bookmark = new Bookmark();
46 $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service'));
47 $bookmark->setUrl('https://shaarli.readthedocs.io', []);
48 $bookmark->setDescription(t(
49 'Welcome to Shaarli! This is your first public bookmark. '
50 . 'To edit or delete me, you must first login.
51
52To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
53
54You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
55 ));
56 $bookmark->setTagsString('opensource software');
57 $this->bookmarkService->add($bookmark);
58 }
59}
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
new file mode 100644
index 00000000..7b7a4f09
--- /dev/null
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -0,0 +1,180 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9use Shaarli\Exceptions\IOException;
10use Shaarli\History;
11
12/**
13 * Class BookmarksService
14 *
15 * This is the entry point to manipulate the bookmark DB.
16 */
17interface BookmarkServiceInterface
18{
19 /**
20 * BookmarksService constructor.
21 *
22 * @param ConfigManager $conf instance
23 * @param History $history instance
24 * @param bool $isLoggedIn true if the current user is logged in
25 */
26 public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
27
28 /**
29 * Find a bookmark by hash
30 *
31 * @param string $hash
32 *
33 * @return mixed
34 *
35 * @throws \Exception
36 */
37 public function findByHash($hash);
38
39 /**
40 * @param $url
41 *
42 * @return Bookmark|null
43 */
44 public function findByUrl($url);
45
46 /**
47 * Search bookmarks
48 *
49 * @param mixed $request
50 * @param string $visibility
51 * @param bool $caseSensitive
52 * @param bool $untaggedOnly
53 *
54 * @return Bookmark[]
55 */
56 public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false);
57
58 /**
59 * Get a single bookmark by its ID.
60 *
61 * @param int $id Bookmark ID
62 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
63 * exception
64 *
65 * @return Bookmark
66 *
67 * @throws BookmarkNotFoundException
68 * @throws \Exception
69 */
70 public function get($id, $visibility = null);
71
72 /**
73 * Updates an existing bookmark (depending on its ID).
74 *
75 * @param Bookmark $bookmark
76 * @param bool $save Writes to the datastore if set to true
77 *
78 * @return Bookmark Updated bookmark
79 *
80 * @throws BookmarkNotFoundException
81 * @throws \Exception
82 */
83 public function set($bookmark, $save = true);
84
85 /**
86 * Adds a new bookmark (the ID must be empty).
87 *
88 * @param Bookmark $bookmark
89 * @param bool $save Writes to the datastore if set to true
90 *
91 * @return Bookmark new bookmark
92 *
93 * @throws \Exception
94 */
95 public function add($bookmark, $save = true);
96
97 /**
98 * Adds or updates a bookmark depending on its ID:
99 * - a Bookmark without ID will be added
100 * - a Bookmark with an existing ID will be updated
101 *
102 * @param Bookmark $bookmark
103 * @param bool $save
104 *
105 * @return Bookmark
106 *
107 * @throws \Exception
108 */
109 public function addOrSet($bookmark, $save = true);
110
111 /**
112 * Deletes a bookmark.
113 *
114 * @param Bookmark $bookmark
115 * @param bool $save
116 *
117 * @throws \Exception
118 */
119 public function remove($bookmark, $save = true);
120
121 /**
122 * Get a single bookmark by its ID.
123 *
124 * @param int $id Bookmark ID
125 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
126 * exception
127 *
128 * @return bool
129 */
130 public function exists($id, $visibility = null);
131
132 /**
133 * Return the number of available bookmarks for given visibility.
134 *
135 * @param string $visibility public|private|all
136 *
137 * @return int Number of bookmarks
138 */
139 public function count($visibility = null);
140
141 /**
142 * Write the datastore.
143 *
144 * @throws NotWritableDataStoreException
145 */
146 public function save();
147
148 /**
149 * Returns the list tags appearing in the bookmarks with the given tags
150 *
151 * @param array $filteringTags tags selecting the bookmarks to consider
152 * @param string $visibility process only all/private/public bookmarks
153 *
154 * @return array tag => bookmarksCount
155 */
156 public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all');
157
158 /**
159 * Returns the list of days containing articles (oldest first)
160 *
161 * @return array containing days (in format YYYYMMDD).
162 */
163 public function days();
164
165 /**
166 * Returns the list of articles for a given day.
167 *
168 * @param string $request day to filter. Format: YYYYMMDD.
169 *
170 * @return Bookmark[] list of shaare found.
171 *
172 * @throws BookmarkNotFoundException
173 */
174 public function filterDay($request);
175
176 /**
177 * Creates the default database after a fresh install.
178 */
179 public function initialize();
180}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index 77eb2d95..88379430 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -1,6 +1,6 @@
1<?php 1<?php
2 2
3use Shaarli\Bookmark\LinkDB; 3use Shaarli\Bookmark\Bookmark;
4 4
5/** 5/**
6 * Get cURL callback function for CURLOPT_WRITEFUNCTION 6 * Get cURL callback function for CURLOPT_WRITEFUNCTION
@@ -188,30 +188,11 @@ function html_extract_tag($tag, $html)
188} 188}
189 189
190/** 190/**
191 * Count private links in given linklist. 191 * In a string, converts URLs to clickable bookmarks.
192 *
193 * @param array|Countable $links Linklist.
194 *
195 * @return int Number of private links.
196 */
197function count_private($links)
198{
199 $cpt = 0;
200 foreach ($links as $link) {
201 if ($link['private']) {
202 $cpt += 1;
203 }
204 }
205
206 return $cpt;
207}
208
209/**
210 * In a string, converts URLs to clickable links.
211 * 192 *
212 * @param string $text input string. 193 * @param string $text input string.
213 * 194 *
214 * @return string returns $text with all links converted to HTML links. 195 * @return string returns $text with all bookmarks converted to HTML bookmarks.
215 * 196 *
216 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 197 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
217 */ 198 */
@@ -279,7 +260,7 @@ function format_description($description, $indexUrl = '')
279 */ 260 */
280function link_small_hash($date, $id) 261function link_small_hash($date, $id)
281{ 262{
282 return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id); 263 return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
283} 264}
284 265
285/** 266/**
diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php
index f9414428..827a3d35 100644
--- a/application/bookmark/exception/LinkNotFoundException.php
+++ b/application/bookmark/exception/BookmarkNotFoundException.php
@@ -3,7 +3,7 @@ namespace Shaarli\Bookmark\Exception;
3 3
4use Exception; 4use Exception;
5 5
6class LinkNotFoundException extends Exception 6class BookmarkNotFoundException extends Exception
7{ 7{
8 /** 8 /**
9 * LinkNotFoundException constructor. 9 * LinkNotFoundException constructor.
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
new file mode 100644
index 00000000..cd48c1e6
--- /dev/null
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -0,0 +1,7 @@
1<?php
2
3
4namespace Shaarli\Bookmark\Exception;
5
6
7class EmptyDataStoreException extends \Exception {}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
new file mode 100644
index 00000000..10c84a6d
--- /dev/null
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -0,0 +1,30 @@
1<?php
2
3namespace Shaarli\Bookmark\Exception;
4
5use Shaarli\Bookmark\Bookmark;
6
7class InvalidBookmarkException extends \Exception
8{
9 public function __construct($bookmark)
10 {
11 if ($bookmark instanceof Bookmark) {
12 if ($bookmark->getCreated() instanceof \DateTime) {
13 $created = $bookmark->getCreated()->format(\DateTime::ATOM);
14 } elseif (empty($bookmark->getCreated())) {
15 $created = '';
16 } else {
17 $created = 'Not a DateTime object';
18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL;
25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL;
27 $this->message .= var_export($bookmark, true);
28 }
29 }
30}
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
new file mode 100644
index 00000000..95f34b50
--- /dev/null
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -0,0 +1,19 @@
1<?php
2
3
4namespace Shaarli\Bookmark\Exception;
5
6
7class NotWritableDataStoreException extends \Exception
8{
9 /**
10 * NotReadableDataStore constructor.
11 *
12 * @param string $dataStore file path
13 */
14 public function __construct($dataStore)
15 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '.
17 'Your data might be corrupted, or your file isn\'t readable.';
18 }
19}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index c95e6800..e45bb4c3 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -389,6 +389,8 @@ class ConfigManager
389 $this->setEmpty('translation.extensions', []); 389 $this->setEmpty('translation.extensions', []);
390 390
391 $this->setEmpty('plugins', array()); 391 $this->setEmpty('plugins', array());
392
393 $this->setEmpty('formatter', 'markdown');
392 } 394 }
393 395
394 /** 396 /**
diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php
index 957c8273..40bd4f15 100644
--- a/application/feed/FeedBuilder.php
+++ b/application/feed/FeedBuilder.php
@@ -2,6 +2,9 @@
2namespace Shaarli\Feed; 2namespace Shaarli\Feed;
3 3
4use DateTime; 4use DateTime;
5use Shaarli\Bookmark\Bookmark;
6use Shaarli\Bookmark\BookmarkServiceInterface;
7use Shaarli\Formatter\BookmarkFormatter;
5 8
6/** 9/**
7 * FeedBuilder class. 10 * FeedBuilder class.
@@ -26,16 +29,21 @@ class FeedBuilder
26 public static $DEFAULT_LANGUAGE = 'en-en'; 29 public static $DEFAULT_LANGUAGE = 'en-en';
27 30
28 /** 31 /**
29 * @var int Number of links to display in a feed by default. 32 * @var int Number of bookmarks to display in a feed by default.
30 */ 33 */
31 public static $DEFAULT_NB_LINKS = 50; 34 public static $DEFAULT_NB_LINKS = 50;
32 35
33 /** 36 /**
34 * @var \Shaarli\Bookmark\LinkDB instance. 37 * @var BookmarkServiceInterface instance.
35 */ 38 */
36 protected $linkDB; 39 protected $linkDB;
37 40
38 /** 41 /**
42 * @var BookmarkFormatter instance.
43 */
44 protected $formatter;
45
46 /**
39 * @var string RSS or ATOM feed. 47 * @var string RSS or ATOM feed.
40 */ 48 */
41 protected $feedType; 49 protected $feedType;
@@ -56,7 +64,7 @@ class FeedBuilder
56 protected $isLoggedIn; 64 protected $isLoggedIn;
57 65
58 /** 66 /**
59 * @var boolean Use permalinks instead of direct links if true. 67 * @var boolean Use permalinks instead of direct bookmarks if true.
60 */ 68 */
61 protected $usePermalinks; 69 protected $usePermalinks;
62 70
@@ -78,16 +86,17 @@ class FeedBuilder
78 /** 86 /**
79 * Feed constructor. 87 * Feed constructor.
80 * 88 *
81 * @param \Shaarli\Bookmark\LinkDB $linkDB LinkDB instance. 89 * @param BookmarkServiceInterface $linkDB LinkDB instance.
90 * @param BookmarkFormatter $formatter instance.
82 * @param string $feedType Type of feed. 91 * @param string $feedType Type of feed.
83 * @param array $serverInfo $_SERVER. 92 * @param array $serverInfo $_SERVER.
84 * @param array $userInput $_GET. 93 * @param array $userInput $_GET.
85 * @param boolean $isLoggedIn True if the user is currently logged in, 94 * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
86 * false otherwise.
87 */ 95 */
88 public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn) 96 public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn)
89 { 97 {
90 $this->linkDB = $linkDB; 98 $this->linkDB = $linkDB;
99 $this->formatter = $formatter;
91 $this->feedType = $feedType; 100 $this->feedType = $feedType;
92 $this->serverInfo = $serverInfo; 101 $this->serverInfo = $serverInfo;
93 $this->userInput = $userInput; 102 $this->userInput = $userInput;
@@ -101,13 +110,13 @@ class FeedBuilder
101 */ 110 */
102 public function buildData() 111 public function buildData()
103 { 112 {
104 // Search for untagged links 113 // Search for untagged bookmarks
105 if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { 114 if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
106 $this->userInput['searchtags'] = false; 115 $this->userInput['searchtags'] = false;
107 } 116 }
108 117
109 // Optionally filter the results: 118 // Optionally filter the results:
110 $linksToDisplay = $this->linkDB->filterSearch($this->userInput); 119 $linksToDisplay = $this->linkDB->search($this->userInput);
111 120
112 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); 121 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
113 122
@@ -118,6 +127,7 @@ class FeedBuilder
118 } 127 }
119 128
120 $pageaddr = escape(index_url($this->serverInfo)); 129 $pageaddr = escape(index_url($this->serverInfo));
130 $this->formatter->addContextData('index_url', $pageaddr);
121 $linkDisplayed = array(); 131 $linkDisplayed = array();
122 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { 132 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
123 $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); 133 $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
@@ -139,54 +149,44 @@ class FeedBuilder
139 /** 149 /**
140 * Build a feed item (one per shaare). 150 * Build a feed item (one per shaare).
141 * 151 *
142 * @param array $link Single link array extracted from LinkDB. 152 * @param Bookmark $link Single link array extracted from LinkDB.
143 * @param string $pageaddr Index URL. 153 * @param string $pageaddr Index URL.
144 * 154 *
145 * @return array Link array with feed attributes. 155 * @return array Link array with feed attributes.
146 */ 156 */
147 protected function buildItem($link, $pageaddr) 157 protected function buildItem($link, $pageaddr)
148 { 158 {
149 $link['guid'] = $pageaddr . '?' . $link['shorturl']; 159 $data = $this->formatter->format($link);
150 // Prepend the root URL for notes 160 $data['guid'] = $pageaddr . '?' . $data['shorturl'];
151 if (is_note($link['url'])) {
152 $link['url'] = $pageaddr . $link['url'];
153 }
154 if ($this->usePermalinks === true) { 161 if ($this->usePermalinks === true) {
155 $permalink = '<a href="' . $link['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>'; 162 $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
156 } else { 163 } else {
157 $permalink = '<a href="' . $link['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>'; 164 $permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
158 } 165 }
159 $link['description'] = format_description($link['description'], $pageaddr); 166 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
160 $link['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
161 167
162 $pubDate = $link['created']; 168 $data['pub_iso_date'] = $this->getIsoDate($data['created']);
163 $link['pub_iso_date'] = $this->getIsoDate($pubDate);
164 169
165 // atom:entry elements MUST contain exactly one atom:updated element. 170 // atom:entry elements MUST contain exactly one atom:updated element.
166 if (!empty($link['updated'])) { 171 if (!empty($link->getUpdated())) {
167 $upDate = $link['updated']; 172 $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM);
168 $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
169 } else { 173 } else {
170 $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM); 174 $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM);
171 } 175 }
172 176
173 // Save the more recent item. 177 // Save the more recent item.
174 if (empty($this->latestDate) || $this->latestDate < $pubDate) { 178 if (empty($this->latestDate) || $this->latestDate < $data['created']) {
175 $this->latestDate = $pubDate; 179 $this->latestDate = $data['created'];
176 } 180 }
177 if (!empty($upDate) && $this->latestDate < $upDate) { 181 if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
178 $this->latestDate = $upDate; 182 $this->latestDate = $data['updated'];
179 } 183 }
180 184
181 $taglist = array_filter(explode(' ', $link['tags']), 'strlen'); 185 return $data;
182 uasort($taglist, 'strcasecmp');
183 $link['taglist'] = $taglist;
184
185 return $link;
186 } 186 }
187 187
188 /** 188 /**
189 * Set this to true to use permalinks instead of direct links. 189 * Set this to true to use permalinks instead of direct bookmarks.
190 * 190 *
191 * @param boolean $usePermalinks true to force permalinks. 191 * @param boolean $usePermalinks true to force permalinks.
192 */ 192 */
@@ -273,11 +273,11 @@ class FeedBuilder
273 * Returns the number of link to display according to 'nb' user input parameter. 273 * Returns the number of link to display according to 'nb' user input parameter.
274 * 274 *
275 * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. 275 * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
276 * If 'nb' is set to 'all', display all filtered links (max parameter). 276 * If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
277 * 277 *
278 * @param int $max maximum number of links to display. 278 * @param int $max maximum number of bookmarks to display.
279 * 279 *
280 * @return int number of links to display. 280 * @return int number of bookmarks to display.
281 */ 281 */
282 public function getNbLinks($max) 282 public function getNbLinks($max)
283 { 283 {
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
new file mode 100644
index 00000000..7550c556
--- /dev/null
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -0,0 +1,81 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5/**
6 * Class BookmarkDefaultFormatter
7 *
8 * Default bookmark formatter.
9 * Escape values for HTML display and automatically add link to URL and hashtags.
10 *
11 * @package Shaarli\Formatter
12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter
14{
15 /**
16 * @inheritdoc
17 */
18 public function formatTitle($bookmark)
19 {
20 return escape($bookmark->getTitle());
21 }
22
23 /**
24 * @inheritdoc
25 */
26 public function formatDescription($bookmark)
27 {
28 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
29 return format_description(escape($bookmark->getDescription()), $indexUrl);
30 }
31
32 /**
33 * @inheritdoc
34 */
35 protected function formatTagList($bookmark)
36 {
37 return escape($bookmark->getTags());
38 }
39
40 /**
41 * @inheritdoc
42 */
43 public function formatTagString($bookmark)
44 {
45 return implode(' ', $this->formatTagList($bookmark));
46 }
47
48 /**
49 * @inheritdoc
50 */
51 public function formatUrl($bookmark)
52 {
53 if (! empty($this->contextData['index_url']) && (
54 startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/')
55 )) {
56 return $this->contextData['index_url'] . escape($bookmark->getUrl());
57 }
58 return escape($bookmark->getUrl());
59 }
60
61 /**
62 * @inheritdoc
63 */
64 protected function formatRealUrl($bookmark)
65 {
66 if (! empty($this->contextData['index_url']) && (
67 startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/')
68 )) {
69 return $this->contextData['index_url'] . escape($bookmark->getUrl());
70 }
71 return escape($bookmark->getUrl());
72 }
73
74 /**
75 * @inheritdoc
76 */
77 protected function formatThumbnail($bookmark)
78 {
79 return escape($bookmark->getThumbnail());
80 }
81}
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
new file mode 100644
index 00000000..c82c3452
--- /dev/null
+++ b/application/formatter/BookmarkFormatter.php
@@ -0,0 +1,256 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use DateTime;
6use Shaarli\Config\ConfigManager;
7use Shaarli\Bookmark\Bookmark;
8
9/**
10 * Class BookmarkFormatter
11 *
12 * Abstract class processing all bookmark attributes through methods designed to be overridden.
13 *
14 * @package Shaarli\Formatter
15 */
16abstract class BookmarkFormatter
17{
18 /**
19 * @var ConfigManager
20 */
21 protected $conf;
22
23 /**
24 * @var array Additional parameters than can be used for specific formatting
25 * e.g. index_url for Feed formatting
26 */
27 protected $contextData = [];
28
29 /**
30 * LinkDefaultFormatter constructor.
31 * @param ConfigManager $conf
32 */
33 public function __construct(ConfigManager $conf)
34 {
35 $this->conf = $conf;
36 }
37
38 /**
39 * Convert a Bookmark into an array usable by templates and plugins.
40 *
41 * All Bookmark attributes are formatted through a format method
42 * that can be overridden in a formatter extending this class.
43 *
44 * @param Bookmark $bookmark instance
45 *
46 * @return array formatted representation of a Bookmark
47 */
48 public function format($bookmark)
49 {
50 $out['id'] = $this->formatId($bookmark);
51 $out['shorturl'] = $this->formatShortUrl($bookmark);
52 $out['url'] = $this->formatUrl($bookmark);
53 $out['real_url'] = $this->formatRealUrl($bookmark);
54 $out['title'] = $this->formatTitle($bookmark);
55 $out['description'] = $this->formatDescription($bookmark);
56 $out['thumbnail'] = $this->formatThumbnail($bookmark);
57 $out['taglist'] = $this->formatTagList($bookmark);
58 $out['tags'] = $this->formatTagString($bookmark);
59 $out['sticky'] = $bookmark->isSticky();
60 $out['private'] = $bookmark->isPrivate();
61 $out['class'] = $this->formatClass($bookmark);
62 $out['created'] = $this->formatCreated($bookmark);
63 $out['updated'] = $this->formatUpdated($bookmark);
64 $out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
65 $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
66 return $out;
67 }
68
69 /**
70 * Add additional data available to formatters.
71 * This is used for example to add `index_url` in description's links.
72 *
73 * @param string $key Context data key
74 * @param string $value Context data value
75 */
76 public function addContextData($key, $value)
77 {
78 $this->contextData[$key] = $value;
79 }
80
81 /**
82 * Format ID
83 *
84 * @param Bookmark $bookmark instance
85 *
86 * @return int formatted ID
87 */
88 protected function formatId($bookmark)
89 {
90 return $bookmark->getId();
91 }
92
93 /**
94 * Format ShortUrl
95 *
96 * @param Bookmark $bookmark instance
97 *
98 * @return string formatted ShortUrl
99 */
100 protected function formatShortUrl($bookmark)
101 {
102 return $bookmark->getShortUrl();
103 }
104
105 /**
106 * Format Url
107 *
108 * @param Bookmark $bookmark instance
109 *
110 * @return string formatted Url
111 */
112 protected function formatUrl($bookmark)
113 {
114 return $bookmark->getUrl();
115 }
116
117 /**
118 * Format RealUrl
119 * Legacy: identical to Url
120 *
121 * @param Bookmark $bookmark instance
122 *
123 * @return string formatted RealUrl
124 */
125 protected function formatRealUrl($bookmark)
126 {
127 return $bookmark->getUrl();
128 }
129
130 /**
131 * Format Title
132 *
133 * @param Bookmark $bookmark instance
134 *
135 * @return string formatted Title
136 */
137 protected function formatTitle($bookmark)
138 {
139 return $bookmark->getTitle();
140 }
141
142 /**
143 * Format Description
144 *
145 * @param Bookmark $bookmark instance
146 *
147 * @return string formatted Description
148 */
149 protected function formatDescription($bookmark)
150 {
151 return $bookmark->getDescription();
152 }
153
154 /**
155 * Format Thumbnail
156 *
157 * @param Bookmark $bookmark instance
158 *
159 * @return string formatted Thumbnail
160 */
161 protected function formatThumbnail($bookmark)
162 {
163 return $bookmark->getThumbnail();
164 }
165
166 /**
167 * Format Tags
168 *
169 * @param Bookmark $bookmark instance
170 *
171 * @return array formatted Tags
172 */
173 protected function formatTagList($bookmark)
174 {
175 return $bookmark->getTags();
176 }
177
178 /**
179 * Format TagString
180 *
181 * @param Bookmark $bookmark instance
182 *
183 * @return string formatted TagString
184 */
185 protected function formatTagString($bookmark)
186 {
187 return implode(' ', $bookmark->getTags());
188 }
189
190 /**
191 * Format Class
192 * Used to add specific CSS class for a link
193 *
194 * @param Bookmark $bookmark instance
195 *
196 * @return string formatted Class
197 */
198 protected function formatClass($bookmark)
199 {
200 return $bookmark->isPrivate() ? 'private' : '';
201 }
202
203 /**
204 * Format Created
205 *
206 * @param Bookmark $bookmark instance
207 *
208 * @return DateTime instance
209 */
210 protected function formatCreated(Bookmark $bookmark)
211 {
212 return $bookmark->getCreated();
213 }
214
215 /**
216 * Format Updated
217 *
218 * @param Bookmark $bookmark instance
219 *
220 * @return DateTime instance
221 */
222 protected function formatUpdated(Bookmark $bookmark)
223 {
224 return $bookmark->getUpdated();
225 }
226
227 /**
228 * Format CreatedTimestamp
229 *
230 * @param Bookmark $bookmark instance
231 *
232 * @return int formatted CreatedTimestamp
233 */
234 protected function formatCreatedTimestamp(Bookmark $bookmark)
235 {
236 if (! empty($bookmark->getCreated())) {
237 return $bookmark->getCreated()->getTimestamp();
238 }
239 return 0;
240 }
241
242 /**
243 * Format UpdatedTimestamp
244 *
245 * @param Bookmark $bookmark instance
246 *
247 * @return int formatted UpdatedTimestamp
248 */
249 protected function formatUpdatedTimestamp(Bookmark $bookmark)
250 {
251 if (! empty($bookmark->getUpdated())) {
252 return $bookmark->getUpdated()->getTimestamp();
253 }
254 return 0;
255 }
256}
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
new file mode 100644
index 00000000..7797bfbf
--- /dev/null
+++ b/application/formatter/BookmarkMarkdownFormatter.php
@@ -0,0 +1,204 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class BookmarkMarkdownFormatter
9 *
10 * Format bookmark description into Markdown format.
11 *
12 * @package Shaarli\Formatter
13 */
14class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
15{
16 /**
17 * When this tag is present in a bookmark, its description should not be processed with Markdown
18 */
19 const NO_MD_TAG = 'nomarkdown';
20
21 /** @var \Parsedown instance */
22 protected $parsedown;
23
24 /** @var bool used to escape HTML in Markdown or not.
25 * It MUST be set to true for shared instance as HTML content can
26 * introduce XSS vulnerabilities.
27 */
28 protected $escape;
29
30 /**
31 * @var array List of allowed protocols for links inside bookmark's description.
32 */
33 protected $allowedProtocols;
34
35 /**
36 * LinkMarkdownFormatter constructor.
37 *
38 * @param ConfigManager $conf instance
39 */
40 public function __construct(ConfigManager $conf)
41 {
42 parent::__construct($conf);
43 $this->parsedown = new \Parsedown();
44 $this->escape = $conf->get('security.markdown_escape', true);
45 $this->allowedProtocols = $conf->get('security.allowed_protocols', []);
46 }
47
48 /**
49 * @inheritdoc
50 */
51 public function formatDescription($bookmark)
52 {
53 if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
54 return parent::formatDescription($bookmark);
55 }
56
57 $processedDescription = $bookmark->getDescription();
58 $processedDescription = $this->filterProtocols($processedDescription);
59 $processedDescription = $this->formatHashTags($processedDescription);
60 $processedDescription = $this->reverseEscapedHtml($processedDescription);
61 $processedDescription = $this->parsedown
62 ->setMarkupEscaped($this->escape)
63 ->setBreaksEnabled(true)
64 ->text($processedDescription);
65 $processedDescription = $this->sanitizeHtml($processedDescription);
66
67 if (!empty($processedDescription)) {
68 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
69 }
70
71 return $processedDescription;
72 }
73
74 /**
75 * Remove the NO markdown tag if it is present
76 *
77 * @inheritdoc
78 */
79 protected function formatTagList($bookmark)
80 {
81 $out = parent::formatTagList($bookmark);
82 if (($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
83 unset($out[$pos]);
84 return array_values($out);
85 }
86 return $out;
87 }
88
89 /**
90 * Replace not whitelisted protocols with http:// in given description.
91 * Also adds `index_url` to relative links if it's specified
92 *
93 * @param string $description input description text.
94 *
95 * @return string $description without malicious link.
96 */
97 protected function filterProtocols($description)
98 {
99 $allowedProtocols = $this->allowedProtocols;
100 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
101
102 return preg_replace_callback(
103 '#]\((.*?)\)#is',
104 function ($match) use ($allowedProtocols, $indexUrl) {
105 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
106 $link .= whitelist_protocols($match[1], $allowedProtocols);
107 return ']('. $link.')';
108 },
109 $description
110 );
111 }
112
113 /**
114 * Replace hashtag in Markdown links format
115 * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)`
116 * It includes the index URL if specified.
117 *
118 * @param string $description
119 *
120 * @return string
121 */
122 protected function formatHashTags($description)
123 {
124 $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
125
126 /*
127 * To support unicode: http://stackoverflow.com/a/35498078/1484919
128 * \p{Pc} - to match underscore
129 * \p{N} - numeric character in any script
130 * \p{L} - letter from any language
131 * \p{Mn} - any non marking space (accents, umlauts, etc)
132 */
133 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
134 $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)';
135
136 $descriptionLines = explode(PHP_EOL, $description);
137 $descriptionOut = '';
138 $codeBlockOn = false;
139 $lineCount = 0;
140
141 foreach ($descriptionLines as $descriptionLine) {
142 // Detect line of code: starting with 4 spaces,
143 // except lists which can start with +/*/- or `2.` after spaces.
144 $codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
145 // Detect and toggle block of code
146 if (!$codeBlockOn) {
147 $codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
148 } elseif (preg_match('/^```/', $descriptionLine) > 0) {
149 $codeBlockOn = false;
150 }
151
152 if (!$codeBlockOn && !$codeLineOn) {
153 $descriptionLine = preg_replace($regex, $replacement, $descriptionLine);
154 }
155
156 $descriptionOut .= $descriptionLine;
157 if ($lineCount++ < count($descriptionLines) - 1) {
158 $descriptionOut .= PHP_EOL;
159 }
160 }
161
162 return $descriptionOut;
163 }
164
165 /**
166 * Remove dangerous HTML tags (tags, iframe, etc.).
167 * Doesn't affect <code> content (already escaped by Parsedown).
168 *
169 * @param string $description input description text.
170 *
171 * @return string given string escaped.
172 */
173 protected function sanitizeHtml($description)
174 {
175 $escapeTags = array(
176 'script',
177 'style',
178 'link',
179 'iframe',
180 'frameset',
181 'frame',
182 );
183 foreach ($escapeTags as $tag) {
184 $description = preg_replace_callback(
185 '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
186 function ($match) {
187 return escape($match[0]);
188 },
189 $description
190 );
191 }
192 $description = preg_replace(
193 '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
194 '$1',
195 $description
196 );
197 return $description;
198 }
199
200 protected function reverseEscapedHtml($description)
201 {
202 return unescape($description);
203 }
204}
diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php
new file mode 100644
index 00000000..bc372273
--- /dev/null
+++ b/application/formatter/BookmarkRawFormatter.php
@@ -0,0 +1,13 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5/**
6 * Class BookmarkRawFormatter
7 *
8 * Used to retrieve bookmarks as array with raw values.
9 * Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
10 *
11 * @package Shaarli\Formatter
12 */
13class BookmarkRawFormatter extends BookmarkFormatter {}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
new file mode 100644
index 00000000..0d2c0466
--- /dev/null
+++ b/application/formatter/FormatterFactory.php
@@ -0,0 +1,46 @@
1<?php
2
3namespace Shaarli\Formatter;
4
5use Shaarli\Config\ConfigManager;
6
7/**
8 * Class FormatterFactory
9 *
10 * Helper class used to instantiate the proper BookmarkFormatter.
11 *
12 * @package Shaarli\Formatter
13 */
14class FormatterFactory
15{
16 /** @var ConfigManager instance */
17 protected $conf;
18
19 /**
20 * FormatterFactory constructor.
21 *
22 * @param ConfigManager $conf
23 */
24 public function __construct(ConfigManager $conf)
25 {
26 $this->conf = $conf;
27 }
28
29 /**
30 * Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
31 *
32 * @param string|null $type force a specific type regardless of the configuration
33 *
34 * @return BookmarkFormatter instance.
35 */
36 public function getFormatter($type = null)
37 {
38 $type = $type ? $type : $this->conf->get('formatter', 'default');
39 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';
40 if (!class_exists($className)) {
41 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
42 }
43
44 return new $className($this->conf);
45 }
46}
diff --git a/application/bookmark/LinkDB.php b/application/legacy/LegacyLinkDB.php
index f01c7ee6..7ccf5e54 100644
--- a/application/bookmark/LinkDB.php
+++ b/application/legacy/LegacyLinkDB.php
@@ -1,17 +1,17 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3namespace Shaarli\Legacy;
4 4
5use ArrayAccess; 5use ArrayAccess;
6use Countable; 6use Countable;
7use DateTime; 7use DateTime;
8use Iterator; 8use Iterator;
9use Shaarli\Bookmark\Exception\LinkNotFoundException; 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
10use Shaarli\Exceptions\IOException; 10use Shaarli\Exceptions\IOException;
11use Shaarli\FileUtils; 11use Shaarli\FileUtils;
12 12
13/** 13/**
14 * Data storage for links. 14 * Data storage for bookmarks.
15 * 15 *
16 * This object behaves like an associative array. 16 * This object behaves like an associative array.
17 * 17 *
@@ -29,8 +29,8 @@ use Shaarli\FileUtils;
29 * - private: Is this link private? 0=no, other value=yes 29 * - private: Is this link private? 0=no, other value=yes
30 * - tags: tags attached to this entry (separated by spaces) 30 * - tags: tags attached to this entry (separated by spaces)
31 * - title Title of the link 31 * - title Title of the link
32 * - url URL of the link. Used for displayable links. 32 * - url URL of the link. Used for displayable bookmarks.
33 * Can be absolute or relative in the database but the relative links 33 * Can be absolute or relative in the database but the relative bookmarks
34 * will be converted to absolute ones in templates. 34 * will be converted to absolute ones in templates.
35 * - real_url Raw URL in stored in the DB (absolute or relative). 35 * - real_url Raw URL in stored in the DB (absolute or relative).
36 * - shorturl Permalink smallhash 36 * - shorturl Permalink smallhash
@@ -49,11 +49,13 @@ use Shaarli\FileUtils;
49 * Example: 49 * Example:
50 * - DB: link #1 (2010-01-01) link #2 (2016-01-01) 50 * - DB: link #1 (2010-01-01) link #2 (2016-01-01)
51 * - Order: #2 #1 51 * - Order: #2 #1
52 * - Import links containing: link #3 (2013-01-01) 52 * - Import bookmarks containing: link #3 (2013-01-01)
53 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01) 53 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
54 * - Real order: #2 #3 #1 54 * - Real order: #2 #3 #1
55 *
56 * @deprecated
55 */ 57 */
56class LinkDB implements Iterator, Countable, ArrayAccess 58class LegacyLinkDB implements Iterator, Countable, ArrayAccess
57{ 59{
58 // Links are stored as a PHP serialized string 60 // Links are stored as a PHP serialized string
59 private $datastore; 61 private $datastore;
@@ -61,7 +63,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
61 // Link date storage format 63 // Link date storage format
62 const LINK_DATE_FORMAT = 'Ymd_His'; 64 const LINK_DATE_FORMAT = 'Ymd_His';
63 65
64 // List of links (associative array) 66 // List of bookmarks (associative array)
65 // - key: link date (e.g. "20110823_124546"), 67 // - key: link date (e.g. "20110823_124546"),
66 // - value: associative array (keys: title, description...) 68 // - value: associative array (keys: title, description...)
67 private $links; 69 private $links;
@@ -71,7 +73,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
71 private $urls; 73 private $urls;
72 74
73 /** 75 /**
74 * @var array List of all links IDS mapped with their array offset. 76 * @var array List of all bookmarks IDS mapped with their array offset.
75 * Map: id->offset. 77 * Map: id->offset.
76 */ 78 */
77 protected $ids; 79 protected $ids;
@@ -82,10 +84,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess
82 // Position in the $this->keys array (for the Iterator interface) 84 // Position in the $this->keys array (for the Iterator interface)
83 private $position; 85 private $position;
84 86
85 // Is the user logged in? (used to filter private links) 87 // Is the user logged in? (used to filter private bookmarks)
86 private $loggedIn; 88 private $loggedIn;
87 89
88 // Hide public links 90 // Hide public bookmarks
89 private $hidePublicLinks; 91 private $hidePublicLinks;
90 92
91 /** 93 /**
@@ -95,7 +97,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
95 * 97 *
96 * @param string $datastore datastore file path. 98 * @param string $datastore datastore file path.
97 * @param boolean $isLoggedIn is the user logged in? 99 * @param boolean $isLoggedIn is the user logged in?
98 * @param boolean $hidePublicLinks if true all links are private. 100 * @param boolean $hidePublicLinks if true all bookmarks are private.
99 */ 101 */
100 public function __construct( 102 public function __construct(
101 $datastore, 103 $datastore,
@@ -280,7 +282,7 @@ You use the community supported version of the original Shaarli project, by Seba
280 */ 282 */
281 private function read() 283 private function read()
282 { 284 {
283 // Public links are hidden and user not logged in => nothing to show 285 // Public bookmarks are hidden and user not logged in => nothing to show
284 if ($this->hidePublicLinks && !$this->loggedIn) { 286 if ($this->hidePublicLinks && !$this->loggedIn) {
285 $this->links = array(); 287 $this->links = array();
286 return; 288 return;
@@ -310,7 +312,7 @@ You use the community supported version of the original Shaarli project, by Seba
310 312
311 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; 313 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
312 314
313 // To be able to load links before running the update, and prepare the update 315 // To be able to load bookmarks before running the update, and prepare the update
314 if (!isset($link['created'])) { 316 if (!isset($link['created'])) {
315 $link['id'] = $link['linkdate']; 317 $link['id'] = $link['linkdate'];
316 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); 318 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
@@ -375,13 +377,13 @@ You use the community supported version of the original Shaarli project, by Seba
375 * 377 *
376 * @return array $filtered array containing permalink data. 378 * @return array $filtered array containing permalink data.
377 * 379 *
378 * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. 380 * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link.
379 */ 381 */
380 public function filterHash($request) 382 public function filterHash($request)
381 { 383 {
382 $request = substr($request, 0, 6); 384 $request = substr($request, 0, 6);
383 $linkFilter = new LinkFilter($this->links); 385 $linkFilter = new LegacyLinkFilter($this->links);
384 return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); 386 return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request);
385 } 387 }
386 388
387 /** 389 /**
@@ -393,21 +395,21 @@ You use the community supported version of the original Shaarli project, by Seba
393 */ 395 */
394 public function filterDay($request) 396 public function filterDay($request)
395 { 397 {
396 $linkFilter = new LinkFilter($this->links); 398 $linkFilter = new LegacyLinkFilter($this->links);
397 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); 399 return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request);
398 } 400 }
399 401
400 /** 402 /**
401 * Filter links according to search parameters. 403 * Filter bookmarks according to search parameters.
402 * 404 *
403 * @param array $filterRequest Search request content. Supported keys: 405 * @param array $filterRequest Search request content. Supported keys:
404 * - searchtags: list of tags 406 * - searchtags: list of tags
405 * - searchterm: term search 407 * - searchterm: term search
406 * @param bool $casesensitive Optional: Perform case sensitive filter 408 * @param bool $casesensitive Optional: Perform case sensitive filter
407 * @param string $visibility return only all/private/public links 409 * @param string $visibility return only all/private/public bookmarks
408 * @param bool $untaggedonly return only untagged links 410 * @param bool $untaggedonly return only untagged bookmarks
409 * 411 *
410 * @return array filtered links, all links if no suitable filter was provided. 412 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
411 */ 413 */
412 public function filterSearch( 414 public function filterSearch(
413 $filterRequest = array(), 415 $filterRequest = array(),
@@ -420,19 +422,19 @@ You use the community supported version of the original Shaarli project, by Seba
420 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; 422 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
421 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; 423 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
422 424
423 // Search tags + fullsearch - blank string parameter will return all links. 425 // Search tags + fullsearch - blank string parameter will return all bookmarks.
424 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" 426 $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext"
425 $request = [$searchtags, $searchterm]; 427 $request = [$searchtags, $searchterm];
426 428
427 $linkFilter = new LinkFilter($this); 429 $linkFilter = new LegacyLinkFilter($this);
428 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); 430 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
429 } 431 }
430 432
431 /** 433 /**
432 * Returns the list tags appearing in the links with the given tags 434 * Returns the list tags appearing in the bookmarks with the given tags
433 * 435 *
434 * @param array $filteringTags tags selecting the links to consider 436 * @param array $filteringTags tags selecting the bookmarks to consider
435 * @param string $visibility process only all/private/public links 437 * @param string $visibility process only all/private/public bookmarks
436 * 438 *
437 * @return array tag => linksCount 439 * @return array tag => linksCount
438 */ 440 */
@@ -471,12 +473,12 @@ You use the community supported version of the original Shaarli project, by Seba
471 } 473 }
472 474
473 /** 475 /**
474 * Rename or delete a tag across all links. 476 * Rename or delete a tag across all bookmarks.
475 * 477 *
476 * @param string $from Tag to rename 478 * @param string $from Tag to rename
477 * @param string $to New tag. If none is provided, the from tag will be deleted 479 * @param string $to New tag. If none is provided, the from tag will be deleted
478 * 480 *
479 * @return array|bool List of altered links or false on error 481 * @return array|bool List of altered bookmarks or false on error
480 */ 482 */
481 public function renameTag($from, $to) 483 public function renameTag($from, $to)
482 { 484 {
@@ -519,7 +521,7 @@ You use the community supported version of the original Shaarli project, by Seba
519 } 521 }
520 522
521 /** 523 /**
522 * Reorder links by creation date (newest first). 524 * Reorder bookmarks by creation date (newest first).
523 * 525 *
524 * Also update the urls and ids mapping arrays. 526 * Also update the urls and ids mapping arrays.
525 * 527 *
@@ -562,7 +564,7 @@ You use the community supported version of the original Shaarli project, by Seba
562 } 564 }
563 565
564 /** 566 /**
565 * Returns a link offset in links array from its unique ID. 567 * Returns a link offset in bookmarks array from its unique ID.
566 * 568 *
567 * @param int $id Persistent ID of a link. 569 * @param int $id Persistent ID of a link.
568 * 570 *
diff --git a/application/bookmark/LinkFilter.php b/application/legacy/LegacyLinkFilter.php
index 9b966307..7cf93d60 100644
--- a/application/bookmark/LinkFilter.php
+++ b/application/legacy/LegacyLinkFilter.php
@@ -1,16 +1,18 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3namespace Shaarli\Legacy;
4 4
5use Exception; 5use Exception;
6use Shaarli\Bookmark\Exception\LinkNotFoundException; 6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7 7
8/** 8/**
9 * Class LinkFilter. 9 * Class LinkFilter.
10 * 10 *
11 * Perform search and filter operation on link data list. 11 * Perform search and filter operation on link data list.
12 *
13 * @deprecated
12 */ 14 */
13class LinkFilter 15class LegacyLinkFilter
14{ 16{
15 /** 17 /**
16 * @var string permalinks. 18 * @var string permalinks.
@@ -38,12 +40,12 @@ class LinkFilter
38 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}'; 40 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
39 41
40 /** 42 /**
41 * @var LinkDB all available links. 43 * @var LegacyLinkDB all available links.
42 */ 44 */
43 private $links; 45 private $links;
44 46
45 /** 47 /**
46 * @param LinkDB $links initialization. 48 * @param LegacyLinkDB $links initialization.
47 */ 49 */
48 public function __construct($links) 50 public function __construct($links)
49 { 51 {
@@ -84,10 +86,10 @@ class LinkFilter
84 $filtered = $this->links; 86 $filtered = $this->links;
85 } 87 }
86 if (!empty($request[0])) { 88 if (!empty($request[0])) {
87 $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 89 $filtered = (new LegacyLinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
88 } 90 }
89 if (!empty($request[1])) { 91 if (!empty($request[1])) {
90 $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); 92 $filtered = (new LegacyLinkFilter($filtered))->filterFulltext($request[1], $visibility);
91 } 93 }
92 return $filtered; 94 return $filtered;
93 case self::$FILTER_TEXT: 95 case self::$FILTER_TEXT:
@@ -137,7 +139,7 @@ class LinkFilter
137 * 139 *
138 * @return array $filtered array containing permalink data. 140 * @return array $filtered array containing permalink data.
139 * 141 *
140 * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. 142 * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
141 */ 143 */
142 private function filterSmallHash($smallHash) 144 private function filterSmallHash($smallHash)
143 { 145 {
@@ -151,7 +153,7 @@ class LinkFilter
151 } 153 }
152 154
153 if (empty($filtered)) { 155 if (empty($filtered)) {
154 throw new LinkNotFoundException(); 156 throw new BookmarkNotFoundException();
155 } 157 }
156 158
157 return $filtered; 159 return $filtered;
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
new file mode 100644
index 00000000..3a5de79f
--- /dev/null
+++ b/application/legacy/LegacyUpdater.php
@@ -0,0 +1,617 @@
1<?php
2
3namespace Shaarli\Legacy;
4
5use Exception;
6use RainTPL;
7use ReflectionClass;
8use ReflectionException;
9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkArray;
13use Shaarli\Bookmark\LinkDB;
14use Shaarli\Bookmark\BookmarkFilter;
15use Shaarli\Bookmark\BookmarkIO;
16use Shaarli\Config\ConfigJson;
17use Shaarli\Config\ConfigManager;
18use Shaarli\Config\ConfigPhp;
19use Shaarli\Exceptions\IOException;
20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Exception\UpdaterException;
22
23/**
24 * Class updater.
25 * Used to update stuff when a new Shaarli's version is reached.
26 * Update methods are ran only once, and the stored in a JSON file.
27 *
28 * @deprecated
29 */
30class LegacyUpdater
31{
32 /**
33 * @var array Updates which are already done.
34 */
35 protected $doneUpdates;
36
37 /**
38 * @var LegacyLinkDB instance.
39 */
40 protected $linkDB;
41
42 /**
43 * @var ConfigManager $conf Configuration Manager instance.
44 */
45 protected $conf;
46
47 /**
48 * @var bool True if the user is logged in, false otherwise.
49 */
50 protected $isLoggedIn;
51
52 /**
53 * @var array $_SESSION
54 */
55 protected $session;
56
57 /**
58 * @var ReflectionMethod[] List of current class methods.
59 */
60 protected $methods;
61
62 /**
63 * Object constructor.
64 *
65 * @param array $doneUpdates Updates which are already done.
66 * @param LegacyLinkDB $linkDB LinkDB instance.
67 * @param ConfigManager $conf Configuration Manager instance.
68 * @param boolean $isLoggedIn True if the user is logged in.
69 * @param array $session $_SESSION (by reference)
70 *
71 * @throws ReflectionException
72 */
73 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = [])
74 {
75 $this->doneUpdates = $doneUpdates;
76 $this->linkDB = $linkDB;
77 $this->conf = $conf;
78 $this->isLoggedIn = $isLoggedIn;
79 $this->session = &$session;
80
81 // Retrieve all update methods.
82 $class = new ReflectionClass($this);
83 $this->methods = $class->getMethods();
84 }
85
86 /**
87 * Run all new updates.
88 * Update methods have to start with 'updateMethod' and return true (on success).
89 *
90 * @return array An array containing ran updates.
91 *
92 * @throws UpdaterException If something went wrong.
93 */
94 public function update()
95 {
96 $updatesRan = array();
97
98 // If the user isn't logged in, exit without updating.
99 if ($this->isLoggedIn !== true) {
100 return $updatesRan;
101 }
102
103 if ($this->methods === null) {
104 throw new UpdaterException(t('Couldn\'t retrieve updater class methods.'));
105 }
106
107 foreach ($this->methods as $method) {
108 // Not an update method or already done, pass.
109 if (!startsWith($method->getName(), 'updateMethod')
110 || in_array($method->getName(), $this->doneUpdates)
111 ) {
112 continue;
113 }
114
115 try {
116 $method->setAccessible(true);
117 $res = $method->invoke($this);
118 // Update method must return true to be considered processed.
119 if ($res === true) {
120 $updatesRan[] = $method->getName();
121 }
122 } catch (Exception $e) {
123 throw new UpdaterException($method, $e);
124 }
125 }
126
127 $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
128
129 return $updatesRan;
130 }
131
132 /**
133 * @return array Updates methods already processed.
134 */
135 public function getDoneUpdates()
136 {
137 return $this->doneUpdates;
138 }
139
140 /**
141 * Move deprecated options.php to config.php.
142 *
143 * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
144 * options.php is not supported anymore.
145 */
146 public function updateMethodMergeDeprecatedConfigFile()
147 {
148 if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
149 include $this->conf->get('resource.data_dir') . '/options.php';
150
151 // Load GLOBALS into config
152 $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
153 $allowedKeys[] = 'config';
154 foreach ($GLOBALS as $key => $value) {
155 if (in_array($key, $allowedKeys)) {
156 $this->conf->set($key, $value);
157 }
158 }
159 $this->conf->write($this->isLoggedIn);
160 unlink($this->conf->get('resource.data_dir') . '/options.php');
161 }
162
163 return true;
164 }
165
166 /**
167 * Move old configuration in PHP to the new config system in JSON format.
168 *
169 * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
170 * It will also convert legacy setting keys to the new ones.
171 */
172 public function updateMethodConfigToJson()
173 {
174 // JSON config already exists, nothing to do.
175 if ($this->conf->getConfigIO() instanceof ConfigJson) {
176 return true;
177 }
178
179 $configPhp = new ConfigPhp();
180 $configJson = new ConfigJson();
181 $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
182 rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
183 $this->conf->setConfigIO($configJson);
184 $this->conf->reload();
185
186 $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
187 foreach (ConfigPhp::$ROOT_KEYS as $key) {
188 $this->conf->set($legacyMap[$key], $oldConfig[$key]);
189 }
190
191 // Set sub config keys (config and plugins)
192 $subConfig = array('config', 'plugins');
193 foreach ($subConfig as $sub) {
194 foreach ($oldConfig[$sub] as $key => $value) {
195 if (isset($legacyMap[$sub . '.' . $key])) {
196 $configKey = $legacyMap[$sub . '.' . $key];
197 } else {
198 $configKey = $sub . '.' . $key;
199 }
200 $this->conf->set($configKey, $value);
201 }
202 }
203
204 try {
205 $this->conf->write($this->isLoggedIn);
206 return true;
207 } catch (IOException $e) {
208 error_log($e->getMessage());
209 return false;
210 }
211 }
212
213 /**
214 * Escape settings which have been manually escaped in every request in previous versions:
215 * - general.title
216 * - general.header_link
217 * - redirector.url
218 *
219 * @return bool true if the update is successful, false otherwise.
220 */
221 public function updateMethodEscapeUnescapedConfig()
222 {
223 try {
224 $this->conf->set('general.title', escape($this->conf->get('general.title')));
225 $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
226 $this->conf->write($this->isLoggedIn);
227 } catch (Exception $e) {
228 error_log($e->getMessage());
229 return false;
230 }
231 return true;
232 }
233
234 /**
235 * Update the database to use the new ID system, which replaces linkdate primary keys.
236 * Also, creation and update dates are now DateTime objects (done by LinkDB).
237 *
238 * Since this update is very sensitve (changing the whole database), the datastore will be
239 * automatically backed up into the file datastore.<datetime>.php.
240 *
241 * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
242 * which will be saved by this method.
243 *
244 * @return bool true if the update is successful, false otherwise.
245 */
246 public function updateMethodDatastoreIds()
247 {
248 $first = 'update';
249 foreach ($this->linkDB as $key => $link) {
250 $first = $key;
251 break;
252 }
253
254 // up to date database
255 if (is_int($first)) {
256 return true;
257 }
258
259 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
260 copy($this->conf->get('resource.datastore'), $save);
261
262 $links = array();
263 foreach ($this->linkDB as $offset => $value) {
264 $links[] = $value;
265 unset($this->linkDB[$offset]);
266 }
267 $links = array_reverse($links);
268 $cpt = 0;
269 foreach ($links as $l) {
270 unset($l['linkdate']);
271 $l['id'] = $cpt;
272 $this->linkDB[$cpt++] = $l;
273 }
274
275 $this->linkDB->save($this->conf->get('resource.page_cache'));
276 $this->linkDB->reorder();
277
278 return true;
279 }
280
281 /**
282 * Rename tags starting with a '-' to work with tag exclusion search.
283 */
284 public function updateMethodRenameDashTags()
285 {
286 $linklist = $this->linkDB->filterSearch();
287 foreach ($linklist as $key => $link) {
288 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
289 $link['tags'] = implode(' ', array_unique(BookmarkFilter::tagsStrToArray($link['tags'], true)));
290 $this->linkDB[$key] = $link;
291 }
292 $this->linkDB->save($this->conf->get('resource.page_cache'));
293 return true;
294 }
295
296 /**
297 * Initialize API settings:
298 * - api.enabled: true
299 * - api.secret: generated secret
300 */
301 public function updateMethodApiSettings()
302 {
303 if ($this->conf->exists('api.secret')) {
304 return true;
305 }
306
307 $this->conf->set('api.enabled', true);
308 $this->conf->set(
309 'api.secret',
310 generate_api_secret(
311 $this->conf->get('credentials.login'),
312 $this->conf->get('credentials.salt')
313 )
314 );
315 $this->conf->write($this->isLoggedIn);
316 return true;
317 }
318
319 /**
320 * New setting: theme name. If the default theme is used, nothing to do.
321 *
322 * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
323 * and the current theme is set as default in the theme setting.
324 *
325 * @return bool true if the update is successful, false otherwise.
326 */
327 public function updateMethodDefaultTheme()
328 {
329 // raintpl_tpl isn't the root template directory anymore.
330 // We run the update only if this folder still contains the template files.
331 $tplDir = $this->conf->get('resource.raintpl_tpl');
332 $tplFile = $tplDir . '/linklist.html';
333 if (!file_exists($tplFile)) {
334 return true;
335 }
336
337 $parent = dirname($tplDir);
338 $this->conf->set('resource.raintpl_tpl', $parent);
339 $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
340 $this->conf->write($this->isLoggedIn);
341
342 // Dependency injection gore
343 RainTPL::$tpl_dir = $tplDir;
344
345 return true;
346 }
347
348 /**
349 * Move the file to inc/user.css to data/user.css.
350 *
351 * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
352 *
353 * @return bool true if the update is successful, false otherwise.
354 */
355 public function updateMethodMoveUserCss()
356 {
357 if (!is_file('inc/user.css')) {
358 return true;
359 }
360
361 return rename('inc/user.css', 'data/user.css');
362 }
363
364 /**
365 * * `markdown_escape` is a new setting, set to true as default.
366 *
367 * If the markdown plugin was already enabled, escaping is disabled to avoid
368 * breaking existing entries.
369 */
370 public function updateMethodEscapeMarkdown()
371 {
372 if ($this->conf->exists('security.markdown_escape')) {
373 return true;
374 }
375
376 if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
377 $this->conf->set('security.markdown_escape', false);
378 } else {
379 $this->conf->set('security.markdown_escape', true);
380 }
381 $this->conf->write($this->isLoggedIn);
382
383 return true;
384 }
385
386 /**
387 * Add 'http://' to Piwik URL the setting is set.
388 *
389 * @return bool true if the update is successful, false otherwise.
390 */
391 public function updateMethodPiwikUrl()
392 {
393 if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
394 return true;
395 }
396
397 $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL'));
398 $this->conf->write($this->isLoggedIn);
399
400 return true;
401 }
402
403 /**
404 * Use ATOM feed as default.
405 */
406 public function updateMethodAtomDefault()
407 {
408 if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
409 return true;
410 }
411
412 $this->conf->set('feed.show_atom', true);
413 $this->conf->write($this->isLoggedIn);
414
415 return true;
416 }
417
418 /**
419 * Update updates.check_updates_branch setting.
420 *
421 * If the current major version digit matches the latest branch
422 * major version digit, we set the branch to `latest`,
423 * otherwise we'll check updates on the `stable` branch.
424 *
425 * No update required for the dev version.
426 *
427 * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
428 *
429 * FIXME! This needs to be removed when we switch to first digit major version
430 * instead of the second one since the versionning process will change.
431 */
432 public function updateMethodCheckUpdateRemoteBranch()
433 {
434 if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
435 return true;
436 }
437
438 // Get latest branch major version digit
439 $latestVersion = ApplicationUtils::getLatestGitVersionCode(
440 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
441 5
442 );
443 if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
444 return false;
445 }
446 $latestMajor = $matches[1];
447
448 // Get current major version digit
449 preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
450 $currentMajor = $matches[1];
451
452 if ($currentMajor === $latestMajor) {
453 $branch = 'latest';
454 } else {
455 $branch = 'stable';
456 }
457 $this->conf->set('updates.check_updates_branch', $branch);
458 $this->conf->write($this->isLoggedIn);
459 return true;
460 }
461
462 /**
463 * Reset history store file due to date format change.
464 */
465 public function updateMethodResetHistoryFile()
466 {
467 if (is_file($this->conf->get('resource.history'))) {
468 unlink($this->conf->get('resource.history'));
469 }
470 return true;
471 }
472
473 /**
474 * Save the datastore -> the link order is now applied when bookmarks are saved.
475 */
476 public function updateMethodReorderDatastore()
477 {
478 $this->linkDB->save($this->conf->get('resource.page_cache'));
479 return true;
480 }
481
482 /**
483 * Change privateonly session key to visibility.
484 */
485 public function updateMethodVisibilitySession()
486 {
487 if (isset($_SESSION['privateonly'])) {
488 unset($_SESSION['privateonly']);
489 $_SESSION['visibility'] = 'private';
490 }
491 return true;
492 }
493
494 /**
495 * Add download size and timeout to the configuration file
496 *
497 * @return bool true if the update is successful, false otherwise.
498 */
499 public function updateMethodDownloadSizeAndTimeoutConf()
500 {
501 if ($this->conf->exists('general.download_max_size')
502 && $this->conf->exists('general.download_timeout')
503 ) {
504 return true;
505 }
506
507 if (!$this->conf->exists('general.download_max_size')) {
508 $this->conf->set('general.download_max_size', 1024 * 1024 * 4);
509 }
510
511 if (!$this->conf->exists('general.download_timeout')) {
512 $this->conf->set('general.download_timeout', 30);
513 }
514
515 $this->conf->write($this->isLoggedIn);
516 return true;
517 }
518
519 /**
520 * * Move thumbnails management to WebThumbnailer, coming with new settings.
521 */
522 public function updateMethodWebThumbnailer()
523 {
524 if ($this->conf->exists('thumbnails.mode')) {
525 return true;
526 }
527
528 $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
529 $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
530 $this->conf->set('thumbnails.width', 125);
531 $this->conf->set('thumbnails.height', 90);
532 $this->conf->remove('thumbnail');
533 $this->conf->write(true);
534
535 if ($thumbnailsEnabled) {
536 $this->session['warnings'][] = t(
537 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
538 );
539 }
540
541 return true;
542 }
543
544 /**
545 * Set sticky = false on all bookmarks
546 *
547 * @return bool true if the update is successful, false otherwise.
548 */
549 public function updateMethodSetSticky()
550 {
551 foreach ($this->linkDB as $key => $link) {
552 if (isset($link['sticky'])) {
553 return true;
554 }
555 $link['sticky'] = false;
556 $this->linkDB[$key] = $link;
557 }
558
559 $this->linkDB->save($this->conf->get('resource.page_cache'));
560
561 return true;
562 }
563
564 /**
565 * Remove redirector settings.
566 */
567 public function updateMethodRemoveRedirector()
568 {
569 $this->conf->remove('redirector');
570 $this->conf->write(true);
571 return true;
572 }
573
574 /**
575 * Migrate the legacy arrays to Bookmark objects.
576 * Also make a backup of the datastore.
577 */
578 public function updateMethodMigrateDatabase()
579 {
580 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '_1.php';
581 if (! copy($this->conf->get('resource.datastore'), $save)) {
582 die('Could not backup the datastore.');
583 }
584
585 $linksArray = new BookmarkArray();
586 foreach ($this->linkDB as $key => $link) {
587 $linksArray[$key] = (new Bookmark())->fromArray($link);
588 }
589 $linksIo = new BookmarkIO($this->conf);
590 $linksIo->write($linksArray);
591
592 return true;
593 }
594
595 /**
596 * Write the `formatter` setting in config file.
597 * Use markdown if the markdown plugin is enabled, the default one otherwise.
598 * Also remove markdown plugin setting as it is now integrated to the core.
599 */
600 public function updateMethodFormatterSetting()
601 {
602 if (!$this->conf->exists('formatter') || $this->conf->get('formatter') === 'default') {
603 $enabledPlugins = $this->conf->get('general.enabled_plugins');
604 if (($pos = array_search('markdown', $enabledPlugins)) !== false) {
605 $formatter = 'markdown';
606 unset($enabledPlugins[$pos]);
607 $this->conf->set('general.enabled_plugins', array_values($enabledPlugins));
608 } else {
609 $formatter = 'default';
610 }
611 $this->conf->set('formatter', $formatter);
612 $this->conf->write(true);
613 }
614
615 return true;
616 }
617}
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index 28665941..d64eef7f 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -7,8 +7,10 @@ use DateTimeZone;
7use Exception; 7use Exception;
8use Katzgrau\KLogger\Logger; 8use Katzgrau\KLogger\Logger;
9use Psr\Log\LogLevel; 9use Psr\Log\LogLevel;
10use Shaarli\Bookmark\LinkDB; 10use Shaarli\Bookmark\Bookmark;
11use Shaarli\Bookmark\BookmarkServiceInterface;
11use Shaarli\Config\ConfigManager; 12use Shaarli\Config\ConfigManager;
13use Shaarli\Formatter\BookmarkFormatter;
12use Shaarli\History; 14use Shaarli\History;
13use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; 15use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
14 16
@@ -20,41 +22,39 @@ class NetscapeBookmarkUtils
20{ 22{
21 23
22 /** 24 /**
23 * Filters links and adds Netscape-formatted fields 25 * Filters bookmarks and adds Netscape-formatted fields
24 * 26 *
25 * Added fields: 27 * Added fields:
26 * - timestamp link addition date, using the Unix epoch format 28 * - timestamp link addition date, using the Unix epoch format
27 * - taglist comma-separated tag list 29 * - taglist comma-separated tag list
28 * 30 *
29 * @param LinkDB $linkDb Link datastore 31 * @param BookmarkServiceInterface $bookmarkService Link datastore
30 * @param string $selection Which links to export: (all|private|public) 32 * @param BookmarkFormatter $formatter instance
31 * @param bool $prependNoteUrl Prepend note permalinks with the server's URL 33 * @param string $selection Which bookmarks to export: (all|private|public)
32 * @param string $indexUrl Absolute URL of the Shaarli index page 34 * @param bool $prependNoteUrl Prepend note permalinks with the server's URL
35 * @param string $indexUrl Absolute URL of the Shaarli index page
33 * 36 *
34 * @throws Exception Invalid export selection 37 * @return array The bookmarks to be exported, with additional fields
38 *@throws Exception Invalid export selection
35 * 39 *
36 * @return array The links to be exported, with additional fields
37 */ 40 */
38 public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl) 41 public static function filterAndFormat(
39 { 42 $bookmarkService,
43 $formatter,
44 $selection,
45 $prependNoteUrl,
46 $indexUrl
47 ) {
40 // see tpl/export.html for possible values 48 // see tpl/export.html for possible values
41 if (!in_array($selection, array('all', 'public', 'private'))) { 49 if (!in_array($selection, array('all', 'public', 'private'))) {
42 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); 50 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
43 } 51 }
44 52
45 $bookmarkLinks = array(); 53 $bookmarkLinks = array();
46 foreach ($linkDb as $link) { 54 foreach ($bookmarkService->search([], $selection) as $bookmark) {
47 if ($link['private'] != 0 && $selection == 'public') { 55 $link = $formatter->format($bookmark);
48 continue; 56 $link['taglist'] = implode(',', $bookmark->getTags());
49 } 57 if ($bookmark->isNote() && $prependNoteUrl) {
50 if ($link['private'] == 0 && $selection == 'private') {
51 continue;
52 }
53 $date = $link['created'];
54 $link['timestamp'] = $date->getTimestamp();
55 $link['taglist'] = str_replace(' ', ',', $link['tags']);
56
57 if (is_note($link['url']) && $prependNoteUrl) {
58 $link['url'] = $indexUrl . $link['url']; 58 $link['url'] = $indexUrl . $link['url'];
59 } 59 }
60 60
@@ -69,9 +69,9 @@ class NetscapeBookmarkUtils
69 * 69 *
70 * @param string $filename name of the file to import 70 * @param string $filename name of the file to import
71 * @param int $filesize size of the file to import 71 * @param int $filesize size of the file to import
72 * @param int $importCount how many links were imported 72 * @param int $importCount how many bookmarks were imported
73 * @param int $overwriteCount how many links were overwritten 73 * @param int $overwriteCount how many bookmarks were overwritten
74 * @param int $skipCount how many links were skipped 74 * @param int $skipCount how many bookmarks were skipped
75 * @param int $duration how many seconds did the import take 75 * @param int $duration how many seconds did the import take
76 * 76 *
77 * @return string Summary of the bookmark import status 77 * @return string Summary of the bookmark import status
@@ -91,7 +91,7 @@ class NetscapeBookmarkUtils
91 $status .= vsprintf( 91 $status .= vsprintf(
92 t( 92 t(
93 'was successfully processed in %d seconds: ' 93 'was successfully processed in %d seconds: '
94 . '%d links imported, %d links overwritten, %d links skipped.' 94 . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
95 ), 95 ),
96 [$duration, $importCount, $overwriteCount, $skipCount] 96 [$duration, $importCount, $overwriteCount, $skipCount]
97 ); 97 );
@@ -102,15 +102,15 @@ class NetscapeBookmarkUtils
102 /** 102 /**
103 * Imports Web bookmarks from an uploaded Netscape bookmark dump 103 * Imports Web bookmarks from an uploaded Netscape bookmark dump
104 * 104 *
105 * @param array $post Server $_POST parameters 105 * @param array $post Server $_POST parameters
106 * @param array $files Server $_FILES parameters 106 * @param array $files Server $_FILES parameters
107 * @param LinkDB $linkDb Loaded LinkDB instance 107 * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance
108 * @param ConfigManager $conf instance 108 * @param ConfigManager $conf instance
109 * @param History $history History instance 109 * @param History $history History instance
110 * 110 *
111 * @return string Summary of the bookmark import status 111 * @return string Summary of the bookmark import status
112 */ 112 */
113 public static function import($post, $files, $linkDb, $conf, $history) 113 public static function import($post, $files, $bookmarkService, $conf, $history)
114 { 114 {
115 $start = time(); 115 $start = time();
116 $filename = $files['filetoupload']['name']; 116 $filename = $files['filetoupload']['name'];
@@ -121,10 +121,10 @@ class NetscapeBookmarkUtils
121 return self::importStatus($filename, $filesize); 121 return self::importStatus($filename, $filesize);
122 } 122 }
123 123
124 // Overwrite existing links? 124 // Overwrite existing bookmarks?
125 $overwrite = !empty($post['overwrite']); 125 $overwrite = !empty($post['overwrite']);
126 126
127 // Add tags to all imported links? 127 // Add tags to all imported bookmarks?
128 if (empty($post['default_tags'])) { 128 if (empty($post['default_tags'])) {
129 $defaultTags = array(); 129 $defaultTags = array();
130 } else { 130 } else {
@@ -134,7 +134,7 @@ class NetscapeBookmarkUtils
134 ); 134 );
135 } 135 }
136 136
137 // links are imported as public by default 137 // bookmarks are imported as public by default
138 $defaultPrivacy = 0; 138 $defaultPrivacy = 0;
139 139
140 $parser = new NetscapeBookmarkParser( 140 $parser = new NetscapeBookmarkParser(
@@ -164,22 +164,18 @@ class NetscapeBookmarkUtils
164 // use value from the imported file 164 // use value from the imported file
165 $private = $bkm['pub'] == '1' ? 0 : 1; 165 $private = $bkm['pub'] == '1' ? 0 : 1;
166 } elseif ($post['privacy'] == 'private') { 166 } elseif ($post['privacy'] == 'private') {
167 // all imported links are private 167 // all imported bookmarks are private
168 $private = 1; 168 $private = 1;
169 } elseif ($post['privacy'] == 'public') { 169 } elseif ($post['privacy'] == 'public') {
170 // all imported links are public 170 // all imported bookmarks are public
171 $private = 0; 171 $private = 0;
172 } 172 }
173 173
174 $newLink = array( 174 $link = $bookmarkService->findByUrl($bkm['uri']);
175 'title' => $bkm['title'], 175 $existingLink = $link !== null;
176 'url' => $bkm['uri'], 176 if (! $existingLink) {
177 'description' => $bkm['note'], 177 $link = new Bookmark();
178 'private' => $private, 178 }
179 'tags' => $bkm['tags']
180 );
181
182 $existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
183 179
184 if ($existingLink !== false) { 180 if ($existingLink !== false) {
185 if ($overwrite === false) { 181 if ($overwrite === false) {
@@ -188,28 +184,25 @@ class NetscapeBookmarkUtils
188 continue; 184 continue;
189 } 185 }
190 186
191 // Overwrite an existing link, keep its date 187 $link->setUpdated(new DateTime());
192 $newLink['id'] = $existingLink['id'];
193 $newLink['created'] = $existingLink['created'];
194 $newLink['updated'] = new DateTime();
195 $newLink['shorturl'] = $existingLink['shorturl'];
196 $linkDb[$existingLink['id']] = $newLink;
197 $importCount++;
198 $overwriteCount++; 188 $overwriteCount++;
199 continue; 189 } else {
190 $newLinkDate = new DateTime('@' . strval($bkm['time']));
191 $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
192 $link->setCreated($newLinkDate);
200 } 193 }
201 194
202 // Add a new link - @ used for UNIX timestamps 195 $link->setTitle($bkm['title']);
203 $newLinkDate = new DateTime('@' . strval($bkm['time'])); 196 $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols'));
204 $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); 197 $link->setDescription($bkm['note']);
205 $newLink['created'] = $newLinkDate; 198 $link->setPrivate($private);
206 $newLink['id'] = $linkDb->getNextId(); 199 $link->setTagsString($bkm['tags']);
207 $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); 200
208 $linkDb[$newLink['id']] = $newLink; 201 $bookmarkService->addOrSet($link, false);
209 $importCount++; 202 $importCount++;
210 } 203 }
211 204
212 $linkDb->save($conf->get('resource.page_cache')); 205 $bookmarkService->save();
213 $history->importLinks(); 206 $history->importLinks();
214 207
215 $duration = time() - $start; 208 $duration = time() - $start;
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index 3f86fc26..65e85aaf 100644
--- a/application/render/PageBuilder.php
+++ b/application/render/PageBuilder.php
@@ -5,7 +5,7 @@ namespace Shaarli\Render;
5use Exception; 5use Exception;
6use RainTPL; 6use RainTPL;
7use Shaarli\ApplicationUtils; 7use Shaarli\ApplicationUtils;
8use Shaarli\Bookmark\LinkDB; 8use Shaarli\Bookmark\BookmarkServiceInterface;
9use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
10use Shaarli\Thumbnailer; 10use Shaarli\Thumbnailer;
11 11
@@ -34,9 +34,9 @@ class PageBuilder
34 protected $session; 34 protected $session;
35 35
36 /** 36 /**
37 * @var LinkDB $linkDB instance. 37 * @var BookmarkServiceInterface $bookmarkService instance.
38 */ 38 */
39 protected $linkDB; 39 protected $bookmarkService;
40 40
41 /** 41 /**
42 * @var null|string XSRF token 42 * @var null|string XSRF token
@@ -52,18 +52,18 @@ class PageBuilder
52 * PageBuilder constructor. 52 * PageBuilder constructor.
53 * $tpl is initialized at false for lazy loading. 53 * $tpl is initialized at false for lazy loading.
54 * 54 *
55 * @param ConfigManager $conf Configuration Manager instance (reference). 55 * @param ConfigManager $conf Configuration Manager instance (reference).
56 * @param array $session $_SESSION array 56 * @param array $session $_SESSION array
57 * @param LinkDB $linkDB instance. 57 * @param BookmarkServiceInterface $linkDB instance.
58 * @param string $token Session token 58 * @param string $token Session token
59 * @param bool $isLoggedIn 59 * @param bool $isLoggedIn
60 */ 60 */
61 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) 61 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
62 { 62 {
63 $this->tpl = false; 63 $this->tpl = false;
64 $this->conf = $conf; 64 $this->conf = $conf;
65 $this->session = $session; 65 $this->session = $session;
66 $this->linkDB = $linkDB; 66 $this->bookmarkService = $linkDB;
67 $this->token = $token; 67 $this->token = $token;
68 $this->isLoggedIn = $isLoggedIn; 68 $this->isLoggedIn = $isLoggedIn;
69 } 69 }
@@ -125,8 +125,8 @@ class PageBuilder
125 125
126 $this->tpl->assign('language', $this->conf->get('translation.language')); 126 $this->tpl->assign('language', $this->conf->get('translation.language'));
127 127
128 if ($this->linkDB !== null) { 128 if ($this->bookmarkService !== null) {
129 $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); 129 $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag());
130 } 130 }
131 131
132 $this->tpl->assign( 132 $this->tpl->assign(
@@ -141,6 +141,8 @@ class PageBuilder
141 unset($_SESSION['warnings']); 141 unset($_SESSION['warnings']);
142 } 142 }
143 143
144 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
145
144 // To be removed with a proper theme configuration. 146 // To be removed with a proper theme configuration.
145 $this->tpl->assign('conf', $this->conf); 147 $this->tpl->assign('conf', $this->conf);
146 } 148 }
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index beb9ea9b..95654d81 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -2,25 +2,14 @@
2 2
3namespace Shaarli\Updater; 3namespace Shaarli\Updater;
4 4
5use Exception;
6use RainTPL;
7use ReflectionClass;
8use ReflectionException;
9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\LinkDB;
12use Shaarli\Bookmark\LinkFilter;
13use Shaarli\Config\ConfigJson;
14use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
15use Shaarli\Config\ConfigPhp; 6use Shaarli\Bookmark\BookmarkServiceInterface;
16use Shaarli\Exceptions\IOException;
17use Shaarli\Thumbnailer;
18use Shaarli\Updater\Exception\UpdaterException; 7use Shaarli\Updater\Exception\UpdaterException;
19 8
20/** 9/**
21 * Class updater. 10 * Class Updater.
22 * Used to update stuff when a new Shaarli's version is reached. 11 * Used to update stuff when a new Shaarli's version is reached.
23 * Update methods are ran only once, and the stored in a JSON file. 12 * Update methods are ran only once, and the stored in a TXT file.
24 */ 13 */
25class Updater 14class Updater
26{ 15{
@@ -30,9 +19,9 @@ class Updater
30 protected $doneUpdates; 19 protected $doneUpdates;
31 20
32 /** 21 /**
33 * @var LinkDB instance. 22 * @var BookmarkServiceInterface instance.
34 */ 23 */
35 protected $linkDB; 24 protected $linkServices;
36 25
37 /** 26 /**
38 * @var ConfigManager $conf Configuration Manager instance. 27 * @var ConfigManager $conf Configuration Manager instance.
@@ -45,36 +34,27 @@ class Updater
45 protected $isLoggedIn; 34 protected $isLoggedIn;
46 35
47 /** 36 /**
48 * @var array $_SESSION 37 * @var \ReflectionMethod[] List of current class methods.
49 */
50 protected $session;
51
52 /**
53 * @var ReflectionMethod[] List of current class methods.
54 */ 38 */
55 protected $methods; 39 protected $methods;
56 40
57 /** 41 /**
58 * Object constructor. 42 * Object constructor.
59 * 43 *
60 * @param array $doneUpdates Updates which are already done. 44 * @param array $doneUpdates Updates which are already done.
61 * @param LinkDB $linkDB LinkDB instance. 45 * @param BookmarkServiceInterface $linkDB LinksService instance.
62 * @param ConfigManager $conf Configuration Manager instance. 46 * @param ConfigManager $conf Configuration Manager instance.
63 * @param boolean $isLoggedIn True if the user is logged in. 47 * @param boolean $isLoggedIn True if the user is logged in.
64 * @param array $session $_SESSION (by reference)
65 *
66 * @throws ReflectionException
67 */ 48 */
68 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = []) 49 public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
69 { 50 {
70 $this->doneUpdates = $doneUpdates; 51 $this->doneUpdates = $doneUpdates;
71 $this->linkDB = $linkDB; 52 $this->linkServices = $linkDB;
72 $this->conf = $conf; 53 $this->conf = $conf;
73 $this->isLoggedIn = $isLoggedIn; 54 $this->isLoggedIn = $isLoggedIn;
74 $this->session = &$session;
75 55
76 // Retrieve all update methods. 56 // Retrieve all update methods.
77 $class = new ReflectionClass($this); 57 $class = new \ReflectionClass($this);
78 $this->methods = $class->getMethods(); 58 $this->methods = $class->getMethods();
79 } 59 }
80 60
@@ -96,12 +76,12 @@ class Updater
96 } 76 }
97 77
98 if ($this->methods === null) { 78 if ($this->methods === null) {
99 throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); 79 throw new UpdaterException('Couldn\'t retrieve LegacyUpdater class methods.');
100 } 80 }
101 81
102 foreach ($this->methods as $method) { 82 foreach ($this->methods as $method) {
103 // Not an update method or already done, pass. 83 // Not an update method or already done, pass.
104 if (!startsWith($method->getName(), 'updateMethod') 84 if (! startsWith($method->getName(), 'updateMethod')
105 || in_array($method->getName(), $this->doneUpdates) 85 || in_array($method->getName(), $this->doneUpdates)
106 ) { 86 ) {
107 continue; 87 continue;
@@ -114,7 +94,7 @@ class Updater
114 if ($res === true) { 94 if ($res === true) {
115 $updatesRan[] = $method->getName(); 95 $updatesRan[] = $method->getName();
116 } 96 }
117 } catch (Exception $e) { 97 } catch (\Exception $e) {
118 throw new UpdaterException($method, $e); 98 throw new UpdaterException($method, $e);
119 } 99 }
120 } 100 }
@@ -131,432 +111,4 @@ class Updater
131 { 111 {
132 return $this->doneUpdates; 112 return $this->doneUpdates;
133 } 113 }
134
135 /**
136 * Move deprecated options.php to config.php.
137 *
138 * Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
139 * options.php is not supported anymore.
140 */
141 public function updateMethodMergeDeprecatedConfigFile()
142 {
143 if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
144 include $this->conf->get('resource.data_dir') . '/options.php';
145
146 // Load GLOBALS into config
147 $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
148 $allowedKeys[] = 'config';
149 foreach ($GLOBALS as $key => $value) {
150 if (in_array($key, $allowedKeys)) {
151 $this->conf->set($key, $value);
152 }
153 }
154 $this->conf->write($this->isLoggedIn);
155 unlink($this->conf->get('resource.data_dir') . '/options.php');
156 }
157
158 return true;
159 }
160
161 /**
162 * Move old configuration in PHP to the new config system in JSON format.
163 *
164 * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
165 * It will also convert legacy setting keys to the new ones.
166 */
167 public function updateMethodConfigToJson()
168 {
169 // JSON config already exists, nothing to do.
170 if ($this->conf->getConfigIO() instanceof ConfigJson) {
171 return true;
172 }
173
174 $configPhp = new ConfigPhp();
175 $configJson = new ConfigJson();
176 $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
177 rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
178 $this->conf->setConfigIO($configJson);
179 $this->conf->reload();
180
181 $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
182 foreach (ConfigPhp::$ROOT_KEYS as $key) {
183 $this->conf->set($legacyMap[$key], $oldConfig[$key]);
184 }
185
186 // Set sub config keys (config and plugins)
187 $subConfig = array('config', 'plugins');
188 foreach ($subConfig as $sub) {
189 foreach ($oldConfig[$sub] as $key => $value) {
190 if (isset($legacyMap[$sub . '.' . $key])) {
191 $configKey = $legacyMap[$sub . '.' . $key];
192 } else {
193 $configKey = $sub . '.' . $key;
194 }
195 $this->conf->set($configKey, $value);
196 }
197 }
198
199 try {
200 $this->conf->write($this->isLoggedIn);
201 return true;
202 } catch (IOException $e) {
203 error_log($e->getMessage());
204 return false;
205 }
206 }
207
208 /**
209 * Escape settings which have been manually escaped in every request in previous versions:
210 * - general.title
211 * - general.header_link
212 * - redirector.url
213 *
214 * @return bool true if the update is successful, false otherwise.
215 */
216 public function updateMethodEscapeUnescapedConfig()
217 {
218 try {
219 $this->conf->set('general.title', escape($this->conf->get('general.title')));
220 $this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
221 $this->conf->write($this->isLoggedIn);
222 } catch (Exception $e) {
223 error_log($e->getMessage());
224 return false;
225 }
226 return true;
227 }
228
229 /**
230 * Update the database to use the new ID system, which replaces linkdate primary keys.
231 * Also, creation and update dates are now DateTime objects (done by LinkDB).
232 *
233 * Since this update is very sensitve (changing the whole database), the datastore will be
234 * automatically backed up into the file datastore.<datetime>.php.
235 *
236 * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
237 * which will be saved by this method.
238 *
239 * @return bool true if the update is successful, false otherwise.
240 */
241 public function updateMethodDatastoreIds()
242 {
243 // up to date database
244 if (isset($this->linkDB[0])) {
245 return true;
246 }
247
248 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
249 copy($this->conf->get('resource.datastore'), $save);
250
251 $links = array();
252 foreach ($this->linkDB as $offset => $value) {
253 $links[] = $value;
254 unset($this->linkDB[$offset]);
255 }
256 $links = array_reverse($links);
257 $cpt = 0;
258 foreach ($links as $l) {
259 unset($l['linkdate']);
260 $l['id'] = $cpt;
261 $this->linkDB[$cpt++] = $l;
262 }
263
264 $this->linkDB->save($this->conf->get('resource.page_cache'));
265 $this->linkDB->reorder();
266
267 return true;
268 }
269
270 /**
271 * Rename tags starting with a '-' to work with tag exclusion search.
272 */
273 public function updateMethodRenameDashTags()
274 {
275 $linklist = $this->linkDB->filterSearch();
276 foreach ($linklist as $key => $link) {
277 $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
278 $link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
279 $this->linkDB[$key] = $link;
280 }
281 $this->linkDB->save($this->conf->get('resource.page_cache'));
282 return true;
283 }
284
285 /**
286 * Initialize API settings:
287 * - api.enabled: true
288 * - api.secret: generated secret
289 */
290 public function updateMethodApiSettings()
291 {
292 if ($this->conf->exists('api.secret')) {
293 return true;
294 }
295
296 $this->conf->set('api.enabled', true);
297 $this->conf->set(
298 'api.secret',
299 generate_api_secret(
300 $this->conf->get('credentials.login'),
301 $this->conf->get('credentials.salt')
302 )
303 );
304 $this->conf->write($this->isLoggedIn);
305 return true;
306 }
307
308 /**
309 * New setting: theme name. If the default theme is used, nothing to do.
310 *
311 * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
312 * and the current theme is set as default in the theme setting.
313 *
314 * @return bool true if the update is successful, false otherwise.
315 */
316 public function updateMethodDefaultTheme()
317 {
318 // raintpl_tpl isn't the root template directory anymore.
319 // We run the update only if this folder still contains the template files.
320 $tplDir = $this->conf->get('resource.raintpl_tpl');
321 $tplFile = $tplDir . '/linklist.html';
322 if (!file_exists($tplFile)) {
323 return true;
324 }
325
326 $parent = dirname($tplDir);
327 $this->conf->set('resource.raintpl_tpl', $parent);
328 $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
329 $this->conf->write($this->isLoggedIn);
330
331 // Dependency injection gore
332 RainTPL::$tpl_dir = $tplDir;
333
334 return true;
335 }
336
337 /**
338 * Move the file to inc/user.css to data/user.css.
339 *
340 * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
341 *
342 * @return bool true if the update is successful, false otherwise.
343 */
344 public function updateMethodMoveUserCss()
345 {
346 if (!is_file('inc/user.css')) {
347 return true;
348 }
349
350 return rename('inc/user.css', 'data/user.css');
351 }
352
353 /**
354 * * `markdown_escape` is a new setting, set to true as default.
355 *
356 * If the markdown plugin was already enabled, escaping is disabled to avoid
357 * breaking existing entries.
358 */
359 public function updateMethodEscapeMarkdown()
360 {
361 if ($this->conf->exists('security.markdown_escape')) {
362 return true;
363 }
364
365 if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
366 $this->conf->set('security.markdown_escape', false);
367 } else {
368 $this->conf->set('security.markdown_escape', true);
369 }
370 $this->conf->write($this->isLoggedIn);
371
372 return true;
373 }
374
375 /**
376 * Add 'http://' to Piwik URL the setting is set.
377 *
378 * @return bool true if the update is successful, false otherwise.
379 */
380 public function updateMethodPiwikUrl()
381 {
382 if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
383 return true;
384 }
385
386 $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL'));
387 $this->conf->write($this->isLoggedIn);
388
389 return true;
390 }
391
392 /**
393 * Use ATOM feed as default.
394 */
395 public function updateMethodAtomDefault()
396 {
397 if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
398 return true;
399 }
400
401 $this->conf->set('feed.show_atom', true);
402 $this->conf->write($this->isLoggedIn);
403
404 return true;
405 }
406
407 /**
408 * Update updates.check_updates_branch setting.
409 *
410 * If the current major version digit matches the latest branch
411 * major version digit, we set the branch to `latest`,
412 * otherwise we'll check updates on the `stable` branch.
413 *
414 * No update required for the dev version.
415 *
416 * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
417 *
418 * FIXME! This needs to be removed when we switch to first digit major version
419 * instead of the second one since the versionning process will change.
420 */
421 public function updateMethodCheckUpdateRemoteBranch()
422 {
423 if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
424 return true;
425 }
426
427 // Get latest branch major version digit
428 $latestVersion = ApplicationUtils::getLatestGitVersionCode(
429 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
430 5
431 );
432 if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
433 return false;
434 }
435 $latestMajor = $matches[1];
436
437 // Get current major version digit
438 preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
439 $currentMajor = $matches[1];
440
441 if ($currentMajor === $latestMajor) {
442 $branch = 'latest';
443 } else {
444 $branch = 'stable';
445 }
446 $this->conf->set('updates.check_updates_branch', $branch);
447 $this->conf->write($this->isLoggedIn);
448 return true;
449 }
450
451 /**
452 * Reset history store file due to date format change.
453 */
454 public function updateMethodResetHistoryFile()
455 {
456 if (is_file($this->conf->get('resource.history'))) {
457 unlink($this->conf->get('resource.history'));
458 }
459 return true;
460 }
461
462 /**
463 * Save the datastore -> the link order is now applied when links are saved.
464 */
465 public function updateMethodReorderDatastore()
466 {
467 $this->linkDB->save($this->conf->get('resource.page_cache'));
468 return true;
469 }
470
471 /**
472 * Change privateonly session key to visibility.
473 */
474 public function updateMethodVisibilitySession()
475 {
476 if (isset($_SESSION['privateonly'])) {
477 unset($_SESSION['privateonly']);
478 $_SESSION['visibility'] = 'private';
479 }
480 return true;
481 }
482
483 /**
484 * Add download size and timeout to the configuration file
485 *
486 * @return bool true if the update is successful, false otherwise.
487 */
488 public function updateMethodDownloadSizeAndTimeoutConf()
489 {
490 if ($this->conf->exists('general.download_max_size')
491 && $this->conf->exists('general.download_timeout')
492 ) {
493 return true;
494 }
495
496 if (!$this->conf->exists('general.download_max_size')) {
497 $this->conf->set('general.download_max_size', 1024 * 1024 * 4);
498 }
499
500 if (!$this->conf->exists('general.download_timeout')) {
501 $this->conf->set('general.download_timeout', 30);
502 }
503
504 $this->conf->write($this->isLoggedIn);
505 return true;
506 }
507
508 /**
509 * * Move thumbnails management to WebThumbnailer, coming with new settings.
510 */
511 public function updateMethodWebThumbnailer()
512 {
513 if ($this->conf->exists('thumbnails.mode')) {
514 return true;
515 }
516
517 $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
518 $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
519 $this->conf->set('thumbnails.width', 125);
520 $this->conf->set('thumbnails.height', 90);
521 $this->conf->remove('thumbnail');
522 $this->conf->write(true);
523
524 if ($thumbnailsEnabled) {
525 $this->session['warnings'][] = t(
526 'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
527 );
528 }
529
530 return true;
531 }
532
533 /**
534 * Set sticky = false on all links
535 *
536 * @return bool true if the update is successful, false otherwise.
537 */
538 public function updateMethodSetSticky()
539 {
540 foreach ($this->linkDB as $key => $link) {
541 if (isset($link['sticky'])) {
542 return true;
543 }
544 $link['sticky'] = false;
545 $this->linkDB[$key] = $link;
546 }
547
548 $this->linkDB->save($this->conf->get('resource.page_cache'));
549
550 return true;
551 }
552
553 /**
554 * Remove redirector settings.
555 */
556 public function updateMethodRemoveRedirector()
557 {
558 $this->conf->remove('redirector');
559 $this->conf->write(true);
560 return true;
561 }
562} 114}
diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php
index 34d4f422..828a49fc 100644
--- a/application/updater/UpdaterUtils.php
+++ b/application/updater/UpdaterUtils.php
@@ -1,39 +1,44 @@
1<?php 1<?php
2 2
3/** 3namespace Shaarli\Updater;
4 * Read the updates file, and return already done updates. 4
5 * 5class UpdaterUtils
6 * @param string $updatesFilepath Updates file path.
7 *
8 * @return array Already done update methods.
9 */
10function read_updates_file($updatesFilepath)
11{ 6{
12 if (! empty($updatesFilepath) && is_file($updatesFilepath)) { 7 /**
13 $content = file_get_contents($updatesFilepath); 8 * Read the updates file, and return already done updates.
14 if (! empty($content)) { 9 *
15 return explode(';', $content); 10 * @param string $updatesFilepath Updates file path.
11 *
12 * @return array Already done update methods.
13 */
14 public static function read_updates_file($updatesFilepath)
15 {
16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
17 $content = file_get_contents($updatesFilepath);
18 if (! empty($content)) {
19 return explode(';', $content);
20 }
16 } 21 }
22 return array();
17 } 23 }
18 return array();
19}
20 24
21/** 25 /**
22 * Write updates file. 26 * Write updates file.
23 * 27 *
24 * @param string $updatesFilepath Updates file path. 28 * @param string $updatesFilepath Updates file path.
25 * @param array $updates Updates array to write. 29 * @param array $updates Updates array to write.
26 * 30 *
27 * @throws Exception Couldn't write version number. 31 * @throws \Exception Couldn't write version number.
28 */ 32 */
29function write_updates_file($updatesFilepath, $updates) 33 public static function write_updates_file($updatesFilepath, $updates)
30{ 34 {
31 if (empty($updatesFilepath)) { 35 if (empty($updatesFilepath)) {
32 throw new Exception(t('Updates file path is not set, can\'t write updates.')); 36 throw new \Exception('Updates file path is not set, can\'t write updates.');
33 } 37 }
34 38
35 $res = file_put_contents($updatesFilepath, implode(';', $updates)); 39 $res = file_put_contents($updatesFilepath, implode(';', $updates));
36 if ($res === false) { 40 if ($res === false) {
37 throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.')); 41 throw new \Exception('Unable to write updates in '. $updatesFilepath . '.');
42 }
38 } 43 }
39} 44}