Apply the new system (Bookmark + Service) to the whole code base

See https://github.com/shaarli/Shaarli/issues/1307
This commit is contained in:
ArthurHoaro 2019-05-25 15:52:27 +02:00
parent 336a28fa4a
commit cf92b4dd15
31 changed files with 634 additions and 1505 deletions

View file

@ -3,6 +3,7 @@ namespace Shaarli;
use DateTime; use DateTime;
use Exception; use Exception;
use Shaarli\Bookmark\Bookmark;
/** /**
* Class History * Class History
@ -20,7 +21,7 @@ use Exception;
* - UPDATED: link updated * - UPDATED: link updated
* - DELETED: link deleted * - DELETED: link deleted
* - SETTINGS: the settings have been updated through the UI. * - SETTINGS: the settings have been updated through the UI.
* - IMPORT: bulk links import * - IMPORT: bulk bookmarks import
* *
* Note: new events are put at the beginning of the file and history array. * Note: new events are put at the beginning of the file and history array.
*/ */
@ -96,31 +97,31 @@ class History
/** /**
* Add Event: new link. * Add Event: new link.
* *
* @param array $link Link data. * @param Bookmark $link Link data.
*/ */
public function addLink($link) public function addLink($link)
{ {
$this->addEvent(self::CREATED, $link['id']); $this->addEvent(self::CREATED, $link->getId());
} }
/** /**
* Add Event: update existing link. * Add Event: update existing link.
* *
* @param array $link Link data. * @param Bookmark $link Link data.
*/ */
public function updateLink($link) public function updateLink($link)
{ {
$this->addEvent(self::UPDATED, $link['id']); $this->addEvent(self::UPDATED, $link->getId());
} }
/** /**
* Add Event: delete existing link. * Add Event: delete existing link.
* *
* @param array $link Link data. * @param Bookmark $link Link data.
*/ */
public function deleteLink($link) public function deleteLink($link)
{ {
$this->addEvent(self::DELETED, $link['id']); $this->addEvent(self::DELETED, $link->getId());
} }
/** /**
@ -134,7 +135,7 @@ class History
/** /**
* Add Event: bulk import. * Add Event: bulk import.
* *
* Note: we don't store links add/update one by one since it can have a huge impact on performances. * Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
*/ */
public function importLinks() public function importLinks()
{ {

View file

@ -162,7 +162,7 @@ function generateLocation($referer, $host, $loopTerms = array())
$finalReferer = '?'; $finalReferer = '?';
// No referer if it contains any value in $loopCriteria. // No referer if it contains any value in $loopCriteria.
foreach ($loopTerms as $value) { foreach (array_filter($loopTerms) as $value) {
if (strpos($referer, $value) !== false) { if (strpos($referer, $value) !== false) {
return $finalReferer; return $finalReferer;
} }

View file

@ -3,6 +3,7 @@ namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Shaarli\Api\Exceptions\ApiException; use Shaarli\Api\Exceptions\ApiException;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Slim\Container; use Slim\Container;
use Slim\Http\Request; use Slim\Http\Request;
@ -117,7 +118,7 @@ class ApiMiddleware
} }
/** /**
* Instantiate a new LinkDB including private links, * Instantiate a new LinkDB including private bookmarks,
* and load in the Slim container. * and load in the Slim container.
* *
* FIXME! LinkDB could use a refactoring to avoid this trick. * FIXME! LinkDB could use a refactoring to avoid this trick.
@ -126,10 +127,10 @@ class ApiMiddleware
*/ */
protected function setLinkDb($conf) protected function setLinkDb($conf)
{ {
$linkDb = new \Shaarli\Bookmark\LinkDB( $linkDb = new BookmarkFileService(
$conf->get('resource.datastore'), $conf,
true, $this->container->get('history'),
$conf->get('privacy.hide_public_links') true
); );
$this->container['db'] = $linkDb; $this->container['db'] = $linkDb;
} }

View file

@ -2,6 +2,7 @@
namespace Shaarli\Api; namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Http\Base64Url; use Shaarli\Http\Base64Url;
/** /**
@ -54,28 +55,28 @@ class ApiUtils
/** /**
* Format a Link for the REST API. * Format a Link for the REST API.
* *
* @param array $link Link data read from the datastore. * @param Bookmark $bookmark Bookmark data read from the datastore.
* @param string $indexUrl Shaarli's index URL (used for relative URL). * @param string $indexUrl Shaarli's index URL (used for relative URL).
* *
* @return array Link data formatted for the REST API. * @return array Link data formatted for the REST API.
*/ */
public static function formatLink($link, $indexUrl) public static function formatLink($bookmark, $indexUrl)
{ {
$out['id'] = $link['id']; $out['id'] = $bookmark->getId();
// Not an internal link // Not an internal link
if (! is_note($link['url'])) { if (! $bookmark->isNote()) {
$out['url'] = $link['url']; $out['url'] = $bookmark->getUrl();
} else { } else {
$out['url'] = $indexUrl . $link['url']; $out['url'] = $indexUrl . $bookmark->getUrl();
} }
$out['shorturl'] = $link['shorturl']; $out['shorturl'] = $bookmark->getShortUrl();
$out['title'] = $link['title']; $out['title'] = $bookmark->getTitle();
$out['description'] = $link['description']; $out['description'] = $bookmark->getDescription();
$out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); $out['tags'] = $bookmark->getTags();
$out['private'] = $link['private'] == true; $out['private'] = $bookmark->isPrivate();
$out['created'] = $link['created']->format(\DateTime::ATOM); $out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
if (! empty($link['updated'])) { if (! empty($bookmark->getUpdated())) {
$out['updated'] = $link['updated']->format(\DateTime::ATOM); $out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
} else { } else {
$out['updated'] = ''; $out['updated'] = '';
} }
@ -83,7 +84,7 @@ class ApiUtils
} }
/** /**
* Convert a link given through a request, to a valid link for LinkDB. * Convert a link given through a request, to a valid Bookmark for the datastore.
* *
* If no URL is provided, it will generate a local note URL. * If no URL is provided, it will generate a local note URL.
* If no title is provided, it will use the URL as title. * If no title is provided, it will use the URL as title.
@ -91,50 +92,42 @@ class ApiUtils
* @param array $input Request Link. * @param array $input Request Link.
* @param bool $defaultPrivate Request Link. * @param bool $defaultPrivate Request Link.
* *
* @return array Formatted link. * @return Bookmark instance.
*/ */
public static function buildLinkFromRequest($input, $defaultPrivate) public static function buildLinkFromRequest($input, $defaultPrivate)
{ {
$input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; $bookmark = new Bookmark();
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
if (isset($input['private'])) { if (isset($input['private'])) {
$private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
} else { } else {
$private = $defaultPrivate; $private = $defaultPrivate;
} }
$link = [ $bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
'title' => ! empty($input['title']) ? $input['title'] : $input['url'], $bookmark->setUrl($url);
'url' => $input['url'], $bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
'description' => ! empty($input['description']) ? $input['description'] : '', $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', $bookmark->setPrivate($private);
'private' => $private,
'created' => new \DateTime(), return $bookmark;
];
return $link;
} }
/** /**
* Update link fields using an updated link object. * Update link fields using an updated link object.
* *
* @param array $oldLink data * @param Bookmark $oldLink data
* @param array $newLink data * @param Bookmark $newLink data
* *
* @return array $oldLink updated with $newLink values * @return Bookmark $oldLink updated with $newLink values
*/ */
public static function updateLink($oldLink, $newLink) public static function updateLink($oldLink, $newLink)
{ {
foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { $oldLink->setTitle($newLink->getTitle());
$oldLink[$field] = $newLink[$field]; $oldLink->setUrl($newLink->getUrl());
} $oldLink->setDescription($newLink->getDescription());
$oldLink['updated'] = new \DateTime(); $oldLink->setTags($newLink->getTags());
$oldLink->setPrivate($newLink->isPrivate());
if (empty($oldLink['url'])) {
$oldLink['url'] = '?' . $oldLink['shorturl'];
}
if (empty($oldLink['title'])) {
$oldLink['title'] = $oldLink['url'];
}
return $oldLink; return $oldLink;
} }
@ -143,7 +136,7 @@ class ApiUtils
* Format a Tag for the REST API. * Format a Tag for the REST API.
* *
* @param string $tag Tag name * @param string $tag Tag name
* @param int $occurrences Number of links using this tag * @param int $occurrences Number of bookmarks using this tag
* *
* @return array Link data formatted for the REST API. * @return array Link data formatted for the REST API.
*/ */

View file

@ -2,7 +2,7 @@
namespace Shaarli\Api\Controllers; namespace Shaarli\Api\Controllers;
use Shaarli\Bookmark\LinkDB; use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Slim\Container; use Slim\Container;
@ -26,9 +26,9 @@ abstract class ApiController
protected $conf; protected $conf;
/** /**
* @var LinkDB * @var BookmarkServiceInterface
*/ */
protected $linkDb; protected $bookmarkService;
/** /**
* @var HistoryController * @var HistoryController
@ -51,7 +51,7 @@ abstract class ApiController
{ {
$this->ci = $ci; $this->ci = $ci;
$this->conf = $ci->get('conf'); $this->conf = $ci->get('conf');
$this->linkDb = $ci->get('db'); $this->bookmarkService = $ci->get('db');
$this->history = $ci->get('history'); $this->history = $ci->get('history');
if ($this->conf->get('dev.debug', false)) { if ($this->conf->get('dev.debug', false)) {
$this->jsonStyle = JSON_PRETTY_PRINT; $this->jsonStyle = JSON_PRETTY_PRINT;

View file

@ -41,7 +41,7 @@ class HistoryController extends ApiController
throw new ApiBadParametersException('Invalid offset'); throw new ApiBadParametersException('Invalid offset');
} }
// limit parameter is either a number of links or 'all' for everything. // limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit'); $limit = $request->getParam('limit');
if (empty($limit)) { if (empty($limit)) {
$limit = count($history); $limit = count($history);

View file

@ -2,6 +2,7 @@
namespace Shaarli\Api\Controllers; namespace Shaarli\Api\Controllers;
use Shaarli\Bookmark\BookmarkFilter;
use Slim\Http\Request; use Slim\Http\Request;
use Slim\Http\Response; use Slim\Http\Response;
@ -26,8 +27,8 @@ class Info extends ApiController
public function getInfo($request, $response) public function getInfo($request, $response)
{ {
$info = [ $info = [
'global_counter' => count($this->linkDb), 'global_counter' => $this->bookmarkService->count(),
'private_counter' => count_private($this->linkDb), 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
'settings' => array( 'settings' => array(
'title' => $this->conf->get('general.title', 'Shaarli'), 'title' => $this->conf->get('general.title', 'Shaarli'),
'header_link' => $this->conf->get('general.header_link', '?'), 'header_link' => $this->conf->get('general.header_link', '?'),

View file

@ -11,7 +11,7 @@ use Slim\Http\Response;
/** /**
* Class Links * Class Links
* *
* REST API Controller: all services related to links collection. * REST API Controller: all services related to bookmarks collection.
* *
* @package Api\Controllers * @package Api\Controllers
* @see http://shaarli.github.io/api-documentation/#links-links-collection * @see http://shaarli.github.io/api-documentation/#links-links-collection
@ -19,12 +19,12 @@ use Slim\Http\Response;
class Links extends ApiController class Links extends ApiController
{ {
/** /**
* @var int Number of links returned if no limit is provided. * @var int Number of bookmarks returned if no limit is provided.
*/ */
public static $DEFAULT_LIMIT = 20; public static $DEFAULT_LIMIT = 20;
/** /**
* Retrieve a list of links, allowing different filters. * Retrieve a list of bookmarks, allowing different filters.
* *
* @param Request $request Slim request. * @param Request $request Slim request.
* @param Response $response Slim response. * @param Response $response Slim response.
@ -36,33 +36,32 @@ class Links extends ApiController
public function getLinks($request, $response) public function getLinks($request, $response)
{ {
$private = $request->getParam('visibility'); $private = $request->getParam('visibility');
$links = $this->linkDb->filterSearch( $bookmarks = $this->bookmarkService->search(
[ [
'searchtags' => $request->getParam('searchtags', ''), 'searchtags' => $request->getParam('searchtags', ''),
'searchterm' => $request->getParam('searchterm', ''), 'searchterm' => $request->getParam('searchterm', ''),
], ],
false,
$private $private
); );
// Return links from the {offset}th link, starting from 0. // Return bookmarks from the {offset}th link, starting from 0.
$offset = $request->getParam('offset'); $offset = $request->getParam('offset');
if (! empty($offset) && ! ctype_digit($offset)) { if (! empty($offset) && ! ctype_digit($offset)) {
throw new ApiBadParametersException('Invalid offset'); throw new ApiBadParametersException('Invalid offset');
} }
$offset = ! empty($offset) ? intval($offset) : 0; $offset = ! empty($offset) ? intval($offset) : 0;
if ($offset > count($links)) { if ($offset > count($bookmarks)) {
return $response->withJson([], 200, $this->jsonStyle); return $response->withJson([], 200, $this->jsonStyle);
} }
// limit parameter is either a number of links or 'all' for everything. // limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit'); $limit = $request->getParam('limit');
if (empty($limit)) { if (empty($limit)) {
$limit = self::$DEFAULT_LIMIT; $limit = self::$DEFAULT_LIMIT;
} elseif (ctype_digit($limit)) { } elseif (ctype_digit($limit)) {
$limit = intval($limit); $limit = intval($limit);
} elseif ($limit === 'all') { } elseif ($limit === 'all') {
$limit = count($links); $limit = count($bookmarks);
} else { } else {
throw new ApiBadParametersException('Invalid limit'); throw new ApiBadParametersException('Invalid limit');
} }
@ -72,12 +71,12 @@ class Links extends ApiController
$out = []; $out = [];
$index = 0; $index = 0;
foreach ($links as $link) { foreach ($bookmarks as $bookmark) {
if (count($out) >= $limit) { if (count($out) >= $limit) {
break; break;
} }
if ($index++ >= $offset) { if ($index++ >= $offset) {
$out[] = ApiUtils::formatLink($link, $indexUrl); $out[] = ApiUtils::formatLink($bookmark, $indexUrl);
} }
} }
@ -97,11 +96,11 @@ class Links extends ApiController
*/ */
public function getLink($request, $response, $args) public function getLink($request, $response, $args)
{ {
if (!isset($this->linkDb[$args['id']])) { if (!$this->bookmarkService->exists($args['id'])) {
throw new ApiLinkNotFoundException(); throw new ApiLinkNotFoundException();
} }
$index = index_url($this->ci['environment']); $index = index_url($this->ci['environment']);
$out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index);
return $response->withJson($out, 200, $this->jsonStyle); return $response->withJson($out, 200, $this->jsonStyle);
} }
@ -117,9 +116,11 @@ class Links extends ApiController
public function postLink($request, $response) public function postLink($request, $response)
{ {
$data = $request->getParsedBody(); $data = $request->getParsedBody();
$link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
// duplicate by URL, return 409 Conflict // duplicate by URL, return 409 Conflict
if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { if (! empty($bookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
) {
return $response->withJson( return $response->withJson(
ApiUtils::formatLink($dup, index_url($this->ci['environment'])), ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
409, 409,
@ -127,23 +128,9 @@ class Links extends ApiController
); );
} }
$link['id'] = $this->linkDb->getNextId(); $this->bookmarkService->add($bookmark);
$link['shorturl'] = link_small_hash($link['created'], $link['id']); $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
$redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]);
// note: general relative URL
if (empty($link['url'])) {
$link['url'] = '?' . $link['shorturl'];
}
if (empty($link['title'])) {
$link['title'] = $link['url'];
}
$this->linkDb[$link['id']] = $link;
$this->linkDb->save($this->conf->get('resource.page_cache'));
$this->history->addLink($link);
$out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
$redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
return $response->withAddedHeader('Location', $redirect) return $response->withAddedHeader('Location', $redirect)
->withJson($out, 201, $this->jsonStyle); ->withJson($out, 201, $this->jsonStyle);
} }
@ -161,18 +148,18 @@ class Links extends ApiController
*/ */
public function putLink($request, $response, $args) public function putLink($request, $response, $args)
{ {
if (! isset($this->linkDb[$args['id']])) { if (! $this->bookmarkService->exists($args['id'])) {
throw new ApiLinkNotFoundException(); throw new ApiLinkNotFoundException();
} }
$index = index_url($this->ci['environment']); $index = index_url($this->ci['environment']);
$data = $request->getParsedBody(); $data = $request->getParsedBody();
$requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
// duplicate URL on a different link, return 409 Conflict // duplicate URL on a different link, return 409 Conflict
if (! empty($requestLink['url']) if (! empty($requestBookmark->getUrl())
&& ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
&& $dup['id'] != $args['id'] && $dup->getId() != $args['id']
) { ) {
return $response->withJson( return $response->withJson(
ApiUtils::formatLink($dup, $index), ApiUtils::formatLink($dup, $index),
@ -181,13 +168,11 @@ class Links extends ApiController
); );
} }
$responseLink = $this->linkDb[$args['id']]; $responseBookmark = $this->bookmarkService->get($args['id']);
$responseLink = ApiUtils::updateLink($responseLink, $requestLink); $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
$this->linkDb[$responseLink['id']] = $responseLink; $this->bookmarkService->set($responseBookmark);
$this->linkDb->save($this->conf->get('resource.page_cache'));
$this->history->updateLink($responseLink);
$out = ApiUtils::formatLink($responseLink, $index); $out = ApiUtils::formatLink($responseBookmark, $index);
return $response->withJson($out, 200, $this->jsonStyle); return $response->withJson($out, 200, $this->jsonStyle);
} }
@ -204,13 +189,11 @@ class Links extends ApiController
*/ */
public function deleteLink($request, $response, $args) public function deleteLink($request, $response, $args)
{ {
if (! isset($this->linkDb[$args['id']])) { if (! $this->bookmarkService->exists($args['id'])) {
throw new ApiLinkNotFoundException(); throw new ApiLinkNotFoundException();
} }
$link = $this->linkDb[$args['id']]; $bookmark = $this->bookmarkService->get($args['id']);
unset($this->linkDb[(int) $args['id']]); $this->bookmarkService->remove($bookmark);
$this->linkDb->save($this->conf->get('resource.page_cache'));
$this->history->deleteLink($link);
return $response->withStatus(204); return $response->withStatus(204);
} }

View file

@ -5,6 +5,7 @@ namespace Shaarli\Api\Controllers;
use Shaarli\Api\ApiUtils; use Shaarli\Api\ApiUtils;
use Shaarli\Api\Exceptions\ApiBadParametersException; use Shaarli\Api\Exceptions\ApiBadParametersException;
use Shaarli\Api\Exceptions\ApiTagNotFoundException; use Shaarli\Api\Exceptions\ApiTagNotFoundException;
use Shaarli\Bookmark\BookmarkFilter;
use Slim\Http\Request; use Slim\Http\Request;
use Slim\Http\Response; use Slim\Http\Response;
@ -18,7 +19,7 @@ use Slim\Http\Response;
class Tags extends ApiController class Tags extends ApiController
{ {
/** /**
* @var int Number of links returned if no limit is provided. * @var int Number of bookmarks returned if no limit is provided.
*/ */
public static $DEFAULT_LIMIT = 'all'; public static $DEFAULT_LIMIT = 'all';
@ -35,7 +36,7 @@ class Tags extends ApiController
public function getTags($request, $response) public function getTags($request, $response)
{ {
$visibility = $request->getParam('visibility'); $visibility = $request->getParam('visibility');
$tags = $this->linkDb->linksCountPerTag([], $visibility); $tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
// Return tags from the {offset}th tag, starting from 0. // Return tags from the {offset}th tag, starting from 0.
$offset = $request->getParam('offset'); $offset = $request->getParam('offset');
@ -47,7 +48,7 @@ class Tags extends ApiController
return $response->withJson([], 200, $this->jsonStyle); return $response->withJson([], 200, $this->jsonStyle);
} }
// limit parameter is either a number of links or 'all' for everything. // limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit'); $limit = $request->getParam('limit');
if (empty($limit)) { if (empty($limit)) {
$limit = self::$DEFAULT_LIMIT; $limit = self::$DEFAULT_LIMIT;
@ -87,7 +88,7 @@ class Tags extends ApiController
*/ */
public function getTag($request, $response, $args) public function getTag($request, $response, $args)
{ {
$tags = $this->linkDb->linksCountPerTag(); $tags = $this->bookmarkService->bookmarksCountPerTag();
if (!isset($tags[$args['tagName']])) { if (!isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException(); throw new ApiTagNotFoundException();
} }
@ -111,7 +112,7 @@ class Tags extends ApiController
*/ */
public function putTag($request, $response, $args) public function putTag($request, $response, $args)
{ {
$tags = $this->linkDb->linksCountPerTag(); $tags = $this->bookmarkService->bookmarksCountPerTag();
if (! isset($tags[$args['tagName']])) { if (! isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException(); throw new ApiTagNotFoundException();
} }
@ -121,13 +122,19 @@ class Tags extends ApiController
throw new ApiBadParametersException('New tag name is required in the request body'); throw new ApiBadParametersException('New tag name is required in the request body');
} }
$updated = $this->linkDb->renameTag($args['tagName'], $data['name']); $bookmarks = $this->bookmarkService->search(
$this->linkDb->save($this->conf->get('resource.page_cache')); ['searchtags' => $args['tagName']],
foreach ($updated as $link) { BookmarkFilter::$ALL,
$this->history->updateLink($link); true
);
foreach ($bookmarks as $bookmark) {
$bookmark->renameTag($args['tagName'], $data['name']);
$this->bookmarkService->set($bookmark, false);
$this->history->updateLink($bookmark);
} }
$this->bookmarkService->save();
$tags = $this->linkDb->linksCountPerTag(); $tags = $this->bookmarkService->bookmarksCountPerTag();
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]); $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
return $response->withJson($out, 200, $this->jsonStyle); return $response->withJson($out, 200, $this->jsonStyle);
} }
@ -145,15 +152,22 @@ class Tags extends ApiController
*/ */
public function deleteTag($request, $response, $args) public function deleteTag($request, $response, $args)
{ {
$tags = $this->linkDb->linksCountPerTag(); $tags = $this->bookmarkService->bookmarksCountPerTag();
if (! isset($tags[$args['tagName']])) { if (! isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException(); throw new ApiTagNotFoundException();
} }
$updated = $this->linkDb->renameTag($args['tagName'], null);
$this->linkDb->save($this->conf->get('resource.page_cache')); $bookmarks = $this->bookmarkService->search(
foreach ($updated as $link) { ['searchtags' => $args['tagName']],
$this->history->updateLink($link); BookmarkFilter::$ALL,
true
);
foreach ($bookmarks as $bookmark) {
$bookmark->deleteTag($args['tagName']);
$this->bookmarkService->set($bookmark, false);
$this->history->updateLink($bookmark);
} }
$this->bookmarkService->save();
return $response->withStatus(204); return $response->withStatus(204);
} }

View file

@ -118,7 +118,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
$realOffset = $this->getBookmarkOffset($offset); $realOffset = $this->getBookmarkOffset($offset);
$url = $this->bookmarks[$realOffset]->getUrl(); $url = $this->bookmarks[$realOffset]->getUrl();
unset($this->urls[$url]); unset($this->urls[$url]);
unset($this->ids[$realOffset]); unset($this->ids[$offset]);
unset($this->bookmarks[$realOffset]); unset($this->bookmarks[$realOffset]);
} }

View file

@ -389,6 +389,8 @@ class ConfigManager
$this->setEmpty('translation.extensions', []); $this->setEmpty('translation.extensions', []);
$this->setEmpty('plugins', array()); $this->setEmpty('plugins', array());
$this->setEmpty('formatter', 'markdown');
} }
/** /**

View file

@ -2,6 +2,9 @@
namespace Shaarli\Feed; namespace Shaarli\Feed;
use DateTime; use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Formatter\BookmarkFormatter;
/** /**
* FeedBuilder class. * FeedBuilder class.
@ -26,15 +29,20 @@ class FeedBuilder
public static $DEFAULT_LANGUAGE = 'en-en'; public static $DEFAULT_LANGUAGE = 'en-en';
/** /**
* @var int Number of links to display in a feed by default. * @var int Number of bookmarks to display in a feed by default.
*/ */
public static $DEFAULT_NB_LINKS = 50; public static $DEFAULT_NB_LINKS = 50;
/** /**
* @var \Shaarli\Bookmark\LinkDB instance. * @var BookmarkServiceInterface instance.
*/ */
protected $linkDB; protected $linkDB;
/**
* @var BookmarkFormatter instance.
*/
protected $formatter;
/** /**
* @var string RSS or ATOM feed. * @var string RSS or ATOM feed.
*/ */
@ -56,7 +64,7 @@ class FeedBuilder
protected $isLoggedIn; protected $isLoggedIn;
/** /**
* @var boolean Use permalinks instead of direct links if true. * @var boolean Use permalinks instead of direct bookmarks if true.
*/ */
protected $usePermalinks; protected $usePermalinks;
@ -78,16 +86,17 @@ class FeedBuilder
/** /**
* Feed constructor. * Feed constructor.
* *
* @param \Shaarli\Bookmark\LinkDB $linkDB LinkDB instance. * @param BookmarkServiceInterface $linkDB LinkDB instance.
* @param BookmarkFormatter $formatter instance.
* @param string $feedType Type of feed. * @param string $feedType Type of feed.
* @param array $serverInfo $_SERVER. * @param array $serverInfo $_SERVER.
* @param array $userInput $_GET. * @param array $userInput $_GET.
* @param boolean $isLoggedIn True if the user is currently logged in, * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
* false otherwise.
*/ */
public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn) public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn)
{ {
$this->linkDB = $linkDB; $this->linkDB = $linkDB;
$this->formatter = $formatter;
$this->feedType = $feedType; $this->feedType = $feedType;
$this->serverInfo = $serverInfo; $this->serverInfo = $serverInfo;
$this->userInput = $userInput; $this->userInput = $userInput;
@ -101,13 +110,13 @@ class FeedBuilder
*/ */
public function buildData() public function buildData()
{ {
// Search for untagged links // Search for untagged bookmarks
if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
$this->userInput['searchtags'] = false; $this->userInput['searchtags'] = false;
} }
// Optionally filter the results: // Optionally filter the results:
$linksToDisplay = $this->linkDB->filterSearch($this->userInput); $linksToDisplay = $this->linkDB->search($this->userInput);
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
@ -118,6 +127,7 @@ class FeedBuilder
} }
$pageaddr = escape(index_url($this->serverInfo)); $pageaddr = escape(index_url($this->serverInfo));
$this->formatter->addContextData('index_url', $pageaddr);
$linkDisplayed = array(); $linkDisplayed = array();
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
@ -139,54 +149,44 @@ class FeedBuilder
/** /**
* Build a feed item (one per shaare). * Build a feed item (one per shaare).
* *
* @param array $link Single link array extracted from LinkDB. * @param Bookmark $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL. * @param string $pageaddr Index URL.
* *
* @return array Link array with feed attributes. * @return array Link array with feed attributes.
*/ */
protected function buildItem($link, $pageaddr) protected function buildItem($link, $pageaddr)
{ {
$link['guid'] = $pageaddr . '?' . $link['shorturl']; $data = $this->formatter->format($link);
// Prepend the root URL for notes $data['guid'] = $pageaddr . '?' . $data['shorturl'];
if (is_note($link['url'])) {
$link['url'] = $pageaddr . $link['url'];
}
if ($this->usePermalinks === true) { if ($this->usePermalinks === true) {
$permalink = '<a href="' . $link['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>'; $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
} else { } else {
$permalink = '<a href="' . $link['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>'; $permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
} }
$link['description'] = format_description($link['description'], $pageaddr); $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
$link['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
$pubDate = $link['created']; $data['pub_iso_date'] = $this->getIsoDate($data['created']);
$link['pub_iso_date'] = $this->getIsoDate($pubDate);
// atom:entry elements MUST contain exactly one atom:updated element. // atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link['updated'])) { if (!empty($link->getUpdated())) {
$upDate = $link['updated']; $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM);
$link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
} else { } else {
$link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM); $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM);
} }
// Save the more recent item. // Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $pubDate) { if (empty($this->latestDate) || $this->latestDate < $data['created']) {
$this->latestDate = $pubDate; $this->latestDate = $data['created'];
} }
if (!empty($upDate) && $this->latestDate < $upDate) { if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
$this->latestDate = $upDate; $this->latestDate = $data['updated'];
} }
$taglist = array_filter(explode(' ', $link['tags']), 'strlen'); return $data;
uasort($taglist, 'strcasecmp');
$link['taglist'] = $taglist;
return $link;
} }
/** /**
* Set this to true to use permalinks instead of direct links. * Set this to true to use permalinks instead of direct bookmarks.
* *
* @param boolean $usePermalinks true to force permalinks. * @param boolean $usePermalinks true to force permalinks.
*/ */
@ -273,11 +273,11 @@ class FeedBuilder
* Returns the number of link to display according to 'nb' user input parameter. * Returns the number of link to display according to 'nb' user input parameter.
* *
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
* If 'nb' is set to 'all', display all filtered links (max parameter). * If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
* *
* @param int $max maximum number of links to display. * @param int $max maximum number of bookmarks to display.
* *
* @return int number of links to display. * @return int number of bookmarks to display.
*/ */
public function getNbLinks($max) public function getNbLinks($max)
{ {

View file

@ -57,6 +57,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
$processedDescription = $bookmark->getDescription(); $processedDescription = $bookmark->getDescription();
$processedDescription = $this->filterProtocols($processedDescription); $processedDescription = $this->filterProtocols($processedDescription);
$processedDescription = $this->formatHashTags($processedDescription); $processedDescription = $this->formatHashTags($processedDescription);
$processedDescription = $this->reverseEscapedHtml($processedDescription);
$processedDescription = $this->parsedown $processedDescription = $this->parsedown
->setMarkupEscaped($this->escape) ->setMarkupEscaped($this->escape)
->setBreaksEnabled(true) ->setBreaksEnabled(true)
@ -195,4 +196,9 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
); );
return $description; return $description;
} }
protected function reverseEscapedHtml($description)
{
return unescape($description);
}
} }

View file

@ -7,8 +7,10 @@ use DateTimeZone;
use Exception; use Exception;
use Katzgrau\KLogger\Logger; use Katzgrau\KLogger\Logger;
use Psr\Log\LogLevel; use Psr\Log\LogLevel;
use Shaarli\Bookmark\LinkDB; use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\BookmarkFormatter;
use Shaarli\History; use Shaarli\History;
use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
@ -20,41 +22,39 @@ class NetscapeBookmarkUtils
{ {
/** /**
* Filters links and adds Netscape-formatted fields * Filters bookmarks and adds Netscape-formatted fields
* *
* Added fields: * Added fields:
* - timestamp link addition date, using the Unix epoch format * - timestamp link addition date, using the Unix epoch format
* - taglist comma-separated tag list * - taglist comma-separated tag list
* *
* @param LinkDB $linkDb Link datastore * @param BookmarkServiceInterface $bookmarkService Link datastore
* @param string $selection Which links to export: (all|private|public) * @param BookmarkFormatter $formatter instance
* @param bool $prependNoteUrl Prepend note permalinks with the server's URL * @param string $selection Which bookmarks to export: (all|private|public)
* @param string $indexUrl Absolute URL of the Shaarli index page * @param bool $prependNoteUrl Prepend note permalinks with the server's URL
* @param string $indexUrl Absolute URL of the Shaarli index page
* *
* @throws Exception Invalid export selection * @return array The bookmarks to be exported, with additional fields
*@throws Exception Invalid export selection
* *
* @return array The links to be exported, with additional fields
*/ */
public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl) public static function filterAndFormat(
{ $bookmarkService,
$formatter,
$selection,
$prependNoteUrl,
$indexUrl
) {
// see tpl/export.html for possible values // see tpl/export.html for possible values
if (!in_array($selection, array('all', 'public', 'private'))) { if (!in_array($selection, array('all', 'public', 'private'))) {
throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
} }
$bookmarkLinks = array(); $bookmarkLinks = array();
foreach ($linkDb as $link) { foreach ($bookmarkService->search([], $selection) as $bookmark) {
if ($link['private'] != 0 && $selection == 'public') { $link = $formatter->format($bookmark);
continue; $link['taglist'] = implode(',', $bookmark->getTags());
} if ($bookmark->isNote() && $prependNoteUrl) {
if ($link['private'] == 0 && $selection == 'private') {
continue;
}
$date = $link['created'];
$link['timestamp'] = $date->getTimestamp();
$link['taglist'] = str_replace(' ', ',', $link['tags']);
if (is_note($link['url']) && $prependNoteUrl) {
$link['url'] = $indexUrl . $link['url']; $link['url'] = $indexUrl . $link['url'];
} }
@ -69,9 +69,9 @@ class NetscapeBookmarkUtils
* *
* @param string $filename name of the file to import * @param string $filename name of the file to import
* @param int $filesize size of the file to import * @param int $filesize size of the file to import
* @param int $importCount how many links were imported * @param int $importCount how many bookmarks were imported
* @param int $overwriteCount how many links were overwritten * @param int $overwriteCount how many bookmarks were overwritten
* @param int $skipCount how many links were skipped * @param int $skipCount how many bookmarks were skipped
* @param int $duration how many seconds did the import take * @param int $duration how many seconds did the import take
* *
* @return string Summary of the bookmark import status * @return string Summary of the bookmark import status
@ -91,7 +91,7 @@ class NetscapeBookmarkUtils
$status .= vsprintf( $status .= vsprintf(
t( t(
'was successfully processed in %d seconds: ' 'was successfully processed in %d seconds: '
. '%d links imported, %d links overwritten, %d links skipped.' . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
), ),
[$duration, $importCount, $overwriteCount, $skipCount] [$duration, $importCount, $overwriteCount, $skipCount]
); );
@ -102,15 +102,15 @@ class NetscapeBookmarkUtils
/** /**
* Imports Web bookmarks from an uploaded Netscape bookmark dump * Imports Web bookmarks from an uploaded Netscape bookmark dump
* *
* @param array $post Server $_POST parameters * @param array $post Server $_POST parameters
* @param array $files Server $_FILES parameters * @param array $files Server $_FILES parameters
* @param LinkDB $linkDb Loaded LinkDB instance * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance
* @param ConfigManager $conf instance * @param ConfigManager $conf instance
* @param History $history History instance * @param History $history History instance
* *
* @return string Summary of the bookmark import status * @return string Summary of the bookmark import status
*/ */
public static function import($post, $files, $linkDb, $conf, $history) public static function import($post, $files, $bookmarkService, $conf, $history)
{ {
$start = time(); $start = time();
$filename = $files['filetoupload']['name']; $filename = $files['filetoupload']['name'];
@ -121,10 +121,10 @@ class NetscapeBookmarkUtils
return self::importStatus($filename, $filesize); return self::importStatus($filename, $filesize);
} }
// Overwrite existing links? // Overwrite existing bookmarks?
$overwrite = !empty($post['overwrite']); $overwrite = !empty($post['overwrite']);
// Add tags to all imported links? // Add tags to all imported bookmarks?
if (empty($post['default_tags'])) { if (empty($post['default_tags'])) {
$defaultTags = array(); $defaultTags = array();
} else { } else {
@ -134,7 +134,7 @@ class NetscapeBookmarkUtils
); );
} }
// links are imported as public by default // bookmarks are imported as public by default
$defaultPrivacy = 0; $defaultPrivacy = 0;
$parser = new NetscapeBookmarkParser( $parser = new NetscapeBookmarkParser(
@ -164,22 +164,18 @@ class NetscapeBookmarkUtils
// use value from the imported file // use value from the imported file
$private = $bkm['pub'] == '1' ? 0 : 1; $private = $bkm['pub'] == '1' ? 0 : 1;
} elseif ($post['privacy'] == 'private') { } elseif ($post['privacy'] == 'private') {
// all imported links are private // all imported bookmarks are private
$private = 1; $private = 1;
} elseif ($post['privacy'] == 'public') { } elseif ($post['privacy'] == 'public') {
// all imported links are public // all imported bookmarks are public
$private = 0; $private = 0;
} }
$newLink = array( $link = $bookmarkService->findByUrl($bkm['uri']);
'title' => $bkm['title'], $existingLink = $link !== null;
'url' => $bkm['uri'], if (! $existingLink) {
'description' => $bkm['note'], $link = new Bookmark();
'private' => $private, }
'tags' => $bkm['tags']
);
$existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
if ($existingLink !== false) { if ($existingLink !== false) {
if ($overwrite === false) { if ($overwrite === false) {
@ -188,28 +184,25 @@ class NetscapeBookmarkUtils
continue; continue;
} }
// Overwrite an existing link, keep its date $link->setUpdated(new DateTime());
$newLink['id'] = $existingLink['id'];
$newLink['created'] = $existingLink['created'];
$newLink['updated'] = new DateTime();
$newLink['shorturl'] = $existingLink['shorturl'];
$linkDb[$existingLink['id']] = $newLink;
$importCount++;
$overwriteCount++; $overwriteCount++;
continue; } else {
$newLinkDate = new DateTime('@' . strval($bkm['time']));
$newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
$link->setCreated($newLinkDate);
} }
// Add a new link - @ used for UNIX timestamps $link->setTitle($bkm['title']);
$newLinkDate = new DateTime('@' . strval($bkm['time'])); $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols'));
$newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); $link->setDescription($bkm['note']);
$newLink['created'] = $newLinkDate; $link->setPrivate($private);
$newLink['id'] = $linkDb->getNextId(); $link->setTagsString($bkm['tags']);
$newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
$linkDb[$newLink['id']] = $newLink; $bookmarkService->addOrSet($link, false);
$importCount++; $importCount++;
} }
$linkDb->save($conf->get('resource.page_cache')); $bookmarkService->save();
$history->importLinks(); $history->importLinks();
$duration = time() - $start; $duration = time() - $start;

View file

@ -5,7 +5,7 @@ namespace Shaarli\Render;
use Exception; use Exception;
use RainTPL; use RainTPL;
use Shaarli\ApplicationUtils; use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\LinkDB; use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Thumbnailer; use Shaarli\Thumbnailer;
@ -34,9 +34,9 @@ class PageBuilder
protected $session; protected $session;
/** /**
* @var LinkDB $linkDB instance. * @var BookmarkServiceInterface $bookmarkService instance.
*/ */
protected $linkDB; protected $bookmarkService;
/** /**
* @var null|string XSRF token * @var null|string XSRF token
@ -52,18 +52,18 @@ class PageBuilder
* PageBuilder constructor. * PageBuilder constructor.
* $tpl is initialized at false for lazy loading. * $tpl is initialized at false for lazy loading.
* *
* @param ConfigManager $conf Configuration Manager instance (reference). * @param ConfigManager $conf Configuration Manager instance (reference).
* @param array $session $_SESSION array * @param array $session $_SESSION array
* @param LinkDB $linkDB instance. * @param BookmarkServiceInterface $linkDB instance.
* @param string $token Session token * @param string $token Session token
* @param bool $isLoggedIn * @param bool $isLoggedIn
*/ */
public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
{ {
$this->tpl = false; $this->tpl = false;
$this->conf = $conf; $this->conf = $conf;
$this->session = $session; $this->session = $session;
$this->linkDB = $linkDB; $this->bookmarkService = $linkDB;
$this->token = $token; $this->token = $token;
$this->isLoggedIn = $isLoggedIn; $this->isLoggedIn = $isLoggedIn;
} }
@ -125,8 +125,8 @@ class PageBuilder
$this->tpl->assign('language', $this->conf->get('translation.language')); $this->tpl->assign('language', $this->conf->get('translation.language'));
if ($this->linkDB !== null) { if ($this->bookmarkService !== null) {
$this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag());
} }
$this->tpl->assign( $this->tpl->assign(
@ -141,6 +141,8 @@ class PageBuilder
unset($_SESSION['warnings']); unset($_SESSION['warnings']);
} }
$this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
// To be removed with a proper theme configuration. // To be removed with a proper theme configuration.
$this->tpl->assign('conf', $this->conf); $this->tpl->assign('conf', $this->conf);
} }

View file

@ -2,25 +2,14 @@
namespace Shaarli\Updater; namespace Shaarli\Updater;
use Exception;
use RainTPL;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\LinkDB;
use Shaarli\Bookmark\LinkFilter;
use Shaarli\Config\ConfigJson;
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Config\ConfigPhp; use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Exceptions\IOException;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Exception\UpdaterException; use Shaarli\Updater\Exception\UpdaterException;
/** /**
* Class updater. * Class Updater.
* Used to update stuff when a new Shaarli's version is reached. * Used to update stuff when a new Shaarli's version is reached.
* Update methods are ran only once, and the stored in a JSON file. * Update methods are ran only once, and the stored in a TXT file.
*/ */
class Updater class Updater
{ {
@ -30,9 +19,9 @@ class Updater
protected $doneUpdates; protected $doneUpdates;
/** /**
* @var LinkDB instance. * @var BookmarkServiceInterface instance.
*/ */
protected $linkDB; protected $linkServices;
/** /**
* @var ConfigManager $conf Configuration Manager instance. * @var ConfigManager $conf Configuration Manager instance.
@ -45,36 +34,27 @@ class Updater
protected $isLoggedIn; protected $isLoggedIn;
/** /**
* @var array $_SESSION * @var \ReflectionMethod[] List of current class methods.
*/
protected $session;
/**
* @var ReflectionMethod[] List of current class methods.
*/ */
protected $methods; protected $methods;
/** /**
* Object constructor. * Object constructor.
* *
* @param array $doneUpdates Updates which are already done. * @param array $doneUpdates Updates which are already done.
* @param LinkDB $linkDB LinkDB instance. * @param BookmarkServiceInterface $linkDB LinksService instance.
* @param ConfigManager $conf Configuration Manager instance. * @param ConfigManager $conf Configuration Manager instance.
* @param boolean $isLoggedIn True if the user is logged in. * @param boolean $isLoggedIn True if the user is logged in.
* @param array $session $_SESSION (by reference)
*
* @throws ReflectionException
*/ */
public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = []) public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
{ {
$this->doneUpdates = $doneUpdates; $this->doneUpdates = $doneUpdates;
$this->linkDB = $linkDB; $this->linkServices = $linkDB;
$this->conf = $conf; $this->conf = $conf;
$this->isLoggedIn = $isLoggedIn; $this->isLoggedIn = $isLoggedIn;
$this->session = &$session;
// Retrieve all update methods. // Retrieve all update methods.
$class = new ReflectionClass($this); $class = new \ReflectionClass($this);
$this->methods = $class->getMethods(); $this->methods = $class->getMethods();
} }
@ -96,12 +76,12 @@ class Updater
} }
if ($this->methods === null) { if ($this->methods === null) {
throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); throw new UpdaterException('Couldn\'t retrieve LegacyUpdater class methods.');
} }
foreach ($this->methods as $method) { foreach ($this->methods as $method) {
// Not an update method or already done, pass. // Not an update method or already done, pass.
if (!startsWith($method->getName(), 'updateMethod') if (! startsWith($method->getName(), 'updateMethod')
|| in_array($method->getName(), $this->doneUpdates) || in_array($method->getName(), $this->doneUpdates)
) { ) {
continue; continue;
@ -114,7 +94,7 @@ class Updater
if ($res === true) { if ($res === true) {
$updatesRan[] = $method->getName(); $updatesRan[] = $method->getName();
} }
} catch (Exception $e) { } catch (\Exception $e) {
throw new UpdaterException($method, $e); throw new UpdaterException($method, $e);
} }
} }
@ -131,432 +111,4 @@ class Updater
{ {
return $this->doneUpdates; return $this->doneUpdates;
} }
/**
* Move deprecated options.php to config.php.
*
* Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
* options.php is not supported anymore.
*/
public function updateMethodMergeDeprecatedConfigFile()
{
if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
include $this->conf->get('resource.data_dir') . '/options.php';
// Load GLOBALS into config
$allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
$allowedKeys[] = 'config';
foreach ($GLOBALS as $key => $value) {
if (in_array($key, $allowedKeys)) {
$this->conf->set($key, $value);
}
}
$this->conf->write($this->isLoggedIn);
unlink($this->conf->get('resource.data_dir') . '/options.php');
}
return true;
}
/**
* Move old configuration in PHP to the new config system in JSON format.
*
* Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
* It will also convert legacy setting keys to the new ones.
*/
public function updateMethodConfigToJson()
{
// JSON config already exists, nothing to do.
if ($this->conf->getConfigIO() instanceof ConfigJson) {
return true;
}
$configPhp = new ConfigPhp();
$configJson = new ConfigJson();
$oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
$this->conf->setConfigIO($configJson);
$this->conf->reload();
$legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
foreach (ConfigPhp::$ROOT_KEYS as $key) {
$this->conf->set($legacyMap[$key], $oldConfig[$key]);
}
// Set sub config keys (config and plugins)
$subConfig = array('config', 'plugins');
foreach ($subConfig as $sub) {
foreach ($oldConfig[$sub] as $key => $value) {
if (isset($legacyMap[$sub . '.' . $key])) {
$configKey = $legacyMap[$sub . '.' . $key];
} else {
$configKey = $sub . '.' . $key;
}
$this->conf->set($configKey, $value);
}
}
try {
$this->conf->write($this->isLoggedIn);
return true;
} catch (IOException $e) {
error_log($e->getMessage());
return false;
}
}
/**
* Escape settings which have been manually escaped in every request in previous versions:
* - general.title
* - general.header_link
* - redirector.url
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodEscapeUnescapedConfig()
{
try {
$this->conf->set('general.title', escape($this->conf->get('general.title')));
$this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
$this->conf->write($this->isLoggedIn);
} catch (Exception $e) {
error_log($e->getMessage());
return false;
}
return true;
}
/**
* Update the database to use the new ID system, which replaces linkdate primary keys.
* Also, creation and update dates are now DateTime objects (done by LinkDB).
*
* Since this update is very sensitve (changing the whole database), the datastore will be
* automatically backed up into the file datastore.<datetime>.php.
*
* LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash),
* which will be saved by this method.
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodDatastoreIds()
{
// up to date database
if (isset($this->linkDB[0])) {
return true;
}
$save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
copy($this->conf->get('resource.datastore'), $save);
$links = array();
foreach ($this->linkDB as $offset => $value) {
$links[] = $value;
unset($this->linkDB[$offset]);
}
$links = array_reverse($links);
$cpt = 0;
foreach ($links as $l) {
unset($l['linkdate']);
$l['id'] = $cpt;
$this->linkDB[$cpt++] = $l;
}
$this->linkDB->save($this->conf->get('resource.page_cache'));
$this->linkDB->reorder();
return true;
}
/**
* Rename tags starting with a '-' to work with tag exclusion search.
*/
public function updateMethodRenameDashTags()
{
$linklist = $this->linkDB->filterSearch();
foreach ($linklist as $key => $link) {
$link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
$link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
$this->linkDB[$key] = $link;
}
$this->linkDB->save($this->conf->get('resource.page_cache'));
return true;
}
/**
* Initialize API settings:
* - api.enabled: true
* - api.secret: generated secret
*/
public function updateMethodApiSettings()
{
if ($this->conf->exists('api.secret')) {
return true;
}
$this->conf->set('api.enabled', true);
$this->conf->set(
'api.secret',
generate_api_secret(
$this->conf->get('credentials.login'),
$this->conf->get('credentials.salt')
)
);
$this->conf->write($this->isLoggedIn);
return true;
}
/**
* New setting: theme name. If the default theme is used, nothing to do.
*
* If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory,
* and the current theme is set as default in the theme setting.
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodDefaultTheme()
{
// raintpl_tpl isn't the root template directory anymore.
// We run the update only if this folder still contains the template files.
$tplDir = $this->conf->get('resource.raintpl_tpl');
$tplFile = $tplDir . '/linklist.html';
if (!file_exists($tplFile)) {
return true;
}
$parent = dirname($tplDir);
$this->conf->set('resource.raintpl_tpl', $parent);
$this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/'));
$this->conf->write($this->isLoggedIn);
// Dependency injection gore
RainTPL::$tpl_dir = $tplDir;
return true;
}
/**
* Move the file to inc/user.css to data/user.css.
*
* Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine.
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodMoveUserCss()
{
if (!is_file('inc/user.css')) {
return true;
}
return rename('inc/user.css', 'data/user.css');
}
/**
* * `markdown_escape` is a new setting, set to true as default.
*
* If the markdown plugin was already enabled, escaping is disabled to avoid
* breaking existing entries.
*/
public function updateMethodEscapeMarkdown()
{
if ($this->conf->exists('security.markdown_escape')) {
return true;
}
if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) {
$this->conf->set('security.markdown_escape', false);
} else {
$this->conf->set('security.markdown_escape', true);
}
$this->conf->write($this->isLoggedIn);
return true;
}
/**
* Add 'http://' to Piwik URL the setting is set.
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodPiwikUrl()
{
if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) {
return true;
}
$this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL'));
$this->conf->write($this->isLoggedIn);
return true;
}
/**
* Use ATOM feed as default.
*/
public function updateMethodAtomDefault()
{
if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) {
return true;
}
$this->conf->set('feed.show_atom', true);
$this->conf->write($this->isLoggedIn);
return true;
}
/**
* Update updates.check_updates_branch setting.
*
* If the current major version digit matches the latest branch
* major version digit, we set the branch to `latest`,
* otherwise we'll check updates on the `stable` branch.
*
* No update required for the dev version.
*
* Note: due to hardcoded URL and lack of dependency injection, this is not unit testable.
*
* FIXME! This needs to be removed when we switch to first digit major version
* instead of the second one since the versionning process will change.
*/
public function updateMethodCheckUpdateRemoteBranch()
{
if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') {
return true;
}
// Get latest branch major version digit
$latestVersion = ApplicationUtils::getLatestGitVersionCode(
'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php',
5
);
if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) {
return false;
}
$latestMajor = $matches[1];
// Get current major version digit
preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches);
$currentMajor = $matches[1];
if ($currentMajor === $latestMajor) {
$branch = 'latest';
} else {
$branch = 'stable';
}
$this->conf->set('updates.check_updates_branch', $branch);
$this->conf->write($this->isLoggedIn);
return true;
}
/**
* Reset history store file due to date format change.
*/
public function updateMethodResetHistoryFile()
{
if (is_file($this->conf->get('resource.history'))) {
unlink($this->conf->get('resource.history'));
}
return true;
}
/**
* Save the datastore -> the link order is now applied when links are saved.
*/
public function updateMethodReorderDatastore()
{
$this->linkDB->save($this->conf->get('resource.page_cache'));
return true;
}
/**
* Change privateonly session key to visibility.
*/
public function updateMethodVisibilitySession()
{
if (isset($_SESSION['privateonly'])) {
unset($_SESSION['privateonly']);
$_SESSION['visibility'] = 'private';
}
return true;
}
/**
* Add download size and timeout to the configuration file
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodDownloadSizeAndTimeoutConf()
{
if ($this->conf->exists('general.download_max_size')
&& $this->conf->exists('general.download_timeout')
) {
return true;
}
if (!$this->conf->exists('general.download_max_size')) {
$this->conf->set('general.download_max_size', 1024 * 1024 * 4);
}
if (!$this->conf->exists('general.download_timeout')) {
$this->conf->set('general.download_timeout', 30);
}
$this->conf->write($this->isLoggedIn);
return true;
}
/**
* * Move thumbnails management to WebThumbnailer, coming with new settings.
*/
public function updateMethodWebThumbnailer()
{
if ($this->conf->exists('thumbnails.mode')) {
return true;
}
$thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true);
$this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
$this->conf->set('thumbnails.width', 125);
$this->conf->set('thumbnails.height', 90);
$this->conf->remove('thumbnail');
$this->conf->write(true);
if ($thumbnailsEnabled) {
$this->session['warnings'][] = t(
'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
);
}
return true;
}
/**
* Set sticky = false on all links
*
* @return bool true if the update is successful, false otherwise.
*/
public function updateMethodSetSticky()
{
foreach ($this->linkDB as $key => $link) {
if (isset($link['sticky'])) {
return true;
}
$link['sticky'] = false;
$this->linkDB[$key] = $link;
}
$this->linkDB->save($this->conf->get('resource.page_cache'));
return true;
}
/**
* Remove redirector settings.
*/
public function updateMethodRemoveRedirector()
{
$this->conf->remove('redirector');
$this->conf->write(true);
return true;
}
} }

View file

@ -1,39 +1,44 @@
<?php <?php
/** namespace Shaarli\Updater;
* Read the updates file, and return already done updates.
* class UpdaterUtils
* @param string $updatesFilepath Updates file path.
*
* @return array Already done update methods.
*/
function read_updates_file($updatesFilepath)
{ {
if (! empty($updatesFilepath) && is_file($updatesFilepath)) { /**
$content = file_get_contents($updatesFilepath); * Read the updates file, and return already done updates.
if (! empty($content)) { *
return explode(';', $content); * @param string $updatesFilepath Updates file path.
*
* @return array Already done update methods.
*/
public static function read_updates_file($updatesFilepath)
{
if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
$content = file_get_contents($updatesFilepath);
if (! empty($content)) {
return explode(';', $content);
}
}
return array();
}
/**
* Write updates file.
*
* @param string $updatesFilepath Updates file path.
* @param array $updates Updates array to write.
*
* @throws \Exception Couldn't write version number.
*/
public static function write_updates_file($updatesFilepath, $updates)
{
if (empty($updatesFilepath)) {
throw new \Exception('Updates file path is not set, can\'t write updates.');
}
$res = file_put_contents($updatesFilepath, implode(';', $updates));
if ($res === false) {
throw new \Exception('Unable to write updates in '. $updatesFilepath . '.');
} }
} }
return array();
}
/**
* Write updates file.
*
* @param string $updatesFilepath Updates file path.
* @param array $updates Updates array to write.
*
* @throws Exception Couldn't write version number.
*/
function write_updates_file($updatesFilepath, $updates)
{
if (empty($updatesFilepath)) {
throw new Exception(t('Updates file path is not set, can\'t write updates.'));
}
$res = file_put_contents($updatesFilepath, implode(';', $updates));
if ($res === false) {
throw new Exception(t('Unable to write updates in '. $updatesFilepath . '.'));
}
} }

View file

@ -155,7 +155,7 @@
} }
/* /*
Remove header links style Remove header bookmarks style
*/ */
#pageheader .md_help a { #pageheader .md_help a {
color: lightgray; color: lightgray;

View file

@ -17,14 +17,6 @@ Alternatively, you can transform to JSON format (and pretty-print if you have `j
php -r 'print(json_encode(unserialize(gzinflate(base64_decode(preg_replace("!.*/\* (.+) \*/.*!", "$1", file_get_contents("data/datastore.php")))))));' | jq . php -r 'print(json_encode(unserialize(gzinflate(base64_decode(preg_replace("!.*/\* (.+) \*/.*!", "$1", file_get_contents("data/datastore.php")))))));' | jq .
``` ```
### Changing the timestamp for a shaare
- Look for `<input type="hidden" name="lf_linkdate" value="{$link.linkdate}">` in `tpl/editlink.tpl` (line 14)
- Replace `type="hidden"` with `type="text"` from this line
- A new date/time field becomes available in the edit/new link dialog.
- You can set the timestamp manually by entering it in the format `YYYMMDD_HHMMS`.
### See also ### See also
- [Add a new custom field to shaares (example patch)](https://gist.github.com/nodiscc/8b0194921f059d7b9ad89a581ecd482c) - [Add a new custom field to shaares (example patch)](https://gist.github.com/nodiscc/8b0194921f059d7b9ad89a581ecd482c)

555
index.php

File diff suppressed because it is too large Load diff

View file

@ -1,102 +0,0 @@
## Markdown Shaarli plugin
Convert all your shaares description to HTML formatted Markdown.
[Read more about Markdown syntax](http://daringfireball.net/projects/markdown/syntax).
Markdown processing is done with [Parsedown library](https://github.com/erusev/parsedown).
### Installation
As a default plugin, it should already be in `tpl/plugins/` directory.
If not, download and unpack it there.
The directory structure should look like:
```
--- plugins
|--- markdown
|--- help.html
|--- markdown.css
|--- markdown.meta
|--- markdown.php
|--- README.md
```
To enable the plugin, just check it in the plugin administration page.
You can also add `markdown` to your list of enabled plugins in `data/config.json.php`
(`general.enabled_plugins` list).
This should look like:
```
"general": {
"enabled_plugins": [
"markdown",
[...]
],
}
```
Parsedown parsing library is imported using Composer. If you installed Shaarli using `git`,
or the `master` branch, run
composer update --no-dev --prefer-dist
### No Markdown tag
If the tag `nomarkdown` is set for a shaare, it won't be converted to Markdown syntax.
> Note: this is a special tag, so it won't be displayed in link list.
### HTML escape
By default, HTML tags are escaped. You can enable HTML tags rendering
by setting `security.markdwon_escape` to `false` in `data/config.json.php`:
```json
{
"security": {
"markdown_escape": false
}
}
```
With this setting, Markdown support HTML tags. For example:
> <strong>strong</strong><strike>strike</strike>
Will render as:
> <strong>strong</strong><strike>strike</strike>
**Warning:**
* This setting might present **security risks** (XSS) on shared instances, even though tags
such as script, iframe, etc should be disabled.
* If you want to shaare HTML code, it is necessary to use inline code or code blocks.
* If your shaared descriptions contained HTML tags before enabling the markdown plugin,
enabling it might break your page.
### Known issue
#### Redirector
If you're using a redirector, you *need* to add a space after a link,
otherwise the rest of the line will be `urlencode`.
```
[link](http://domain.tld)-->test
```
Will consider `http://domain.tld)-->test` as URL.
Instead, add an additional space.
```
[link](http://domain.tld) -->test
```
> Won't fix because a `)` is a valid part of an URL.

View file

@ -1,5 +0,0 @@
<div class="md_help">
%s
<a href="http://daringfireball.net/projects/markdown/syntax" title="%s">
%s</a>.
</div>

View file

@ -1,4 +0,0 @@
description="Render shaare description with Markdown syntax.<br><strong>Warning</strong>:
If your shaared descriptions contained HTML tags before enabling the markdown plugin,
enabling it might break your page.
See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/markdown#html-rendering\">README</a>."

View file

@ -1,365 +0,0 @@
<?php
/**
* Plugin Markdown.
*
* Shaare's descriptions are parsed with Markdown.
*/
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\PluginManager;
use Shaarli\Router;
/*
* If this tag is used on a shaare, the description won't be processed by Parsedown.
*/
define('NO_MD_TAG', 'nomarkdown');
/**
* Parse linklist descriptions.
*
* @param array $data linklist data.
* @param ConfigManager $conf instance.
*
* @return mixed linklist data parsed in markdown (and converted to HTML).
*/
function hook_markdown_render_linklist($data, $conf)
{
foreach ($data['links'] as &$value) {
if (!empty($value['tags']) && noMarkdownTag($value['tags'])) {
$value = stripNoMarkdownTag($value);
continue;
}
$value['description_src'] = $value['description'];
$value['description'] = process_markdown(
$value['description'],
$conf->get('security.markdown_escape', true),
$conf->get('security.allowed_protocols')
);
}
return $data;
}
/**
* Parse feed linklist descriptions.
*
* @param array $data linklist data.
* @param ConfigManager $conf instance.
*
* @return mixed linklist data parsed in markdown (and converted to HTML).
*/
function hook_markdown_render_feed($data, $conf)
{
foreach ($data['links'] as &$value) {
if (!empty($value['tags']) && noMarkdownTag($value['tags'])) {
$value = stripNoMarkdownTag($value);
continue;
}
$value['description'] = reverse_feed_permalink($value['description']);
$value['description'] = process_markdown(
$value['description'],
$conf->get('security.markdown_escape', true),
$conf->get('security.allowed_protocols')
);
}
return $data;
}
/**
* Parse daily descriptions.
*
* @param array $data daily data.
* @param ConfigManager $conf instance.
*
* @return mixed daily data parsed in markdown (and converted to HTML).
*/
function hook_markdown_render_daily($data, $conf)
{
//var_dump($data);die;
// Manipulate columns data
foreach ($data['linksToDisplay'] as &$value) {
if (!empty($value['tags']) && noMarkdownTag($value['tags'])) {
$value = stripNoMarkdownTag($value);
continue;
}
$value['formatedDescription'] = process_markdown(
$value['formatedDescription'],
$conf->get('security.markdown_escape', true),
$conf->get('security.allowed_protocols')
);
}
return $data;
}
/**
* Check if noMarkdown is set in tags.
*
* @param string $tags tag list
*
* @return bool true if markdown should be disabled on this link.
*/
function noMarkdownTag($tags)
{
return preg_match('/(^|\s)'. NO_MD_TAG .'(\s|$)/', $tags);
}
/**
* Remove the no-markdown meta tag so it won't be displayed.
*
* @param array $link Link data.
*
* @return array Updated link without no markdown tag.
*/
function stripNoMarkdownTag($link)
{
if (! empty($link['taglist'])) {
$offset = array_search(NO_MD_TAG, $link['taglist']);
if ($offset !== false) {
unset($link['taglist'][$offset]);
}
}
if (!empty($link['tags'])) {
str_replace(NO_MD_TAG, '', $link['tags']);
}
return $link;
}
/**
* When link list is displayed, include markdown CSS.
*
* @param array $data includes data.
*
* @return mixed - includes data with markdown CSS file added.
*/
function hook_markdown_render_includes($data)
{
if ($data['_PAGE_'] == Router::$PAGE_LINKLIST
|| $data['_PAGE_'] == Router::$PAGE_DAILY
|| $data['_PAGE_'] == Router::$PAGE_EDITLINK
) {
$data['css_files'][] = PluginManager::$PLUGINS_PATH . '/markdown/markdown.css';
}
return $data;
}
/**
* Hook render_editlink.
* Adds an help link to markdown syntax.
*
* @param array $data data passed to plugin
*
* @return array altered $data.
*/
function hook_markdown_render_editlink($data)
{
// Load help HTML into a string
$txt = file_get_contents(PluginManager::$PLUGINS_PATH .'/markdown/help.html');
$translations = [
t('Description will be rendered with'),
t('Markdown syntax documentation'),
t('Markdown syntax'),
];
$data['edit_link_plugin'][] = vsprintf($txt, $translations);
// Add no markdown 'meta-tag' in tag list if it was never used, for autocompletion.
if (! in_array(NO_MD_TAG, $data['tags'])) {
$data['tags'][NO_MD_TAG] = 0;
}
return $data;
}
/**
* Remove HTML links auto generated by Shaarli core system.
* Keeps HREF attributes.
*
* @param string $description input description text.
*
* @return string $description without HTML links.
*/
function reverse_text2clickable($description)
{
$descriptionLines = explode(PHP_EOL, $description);
$descriptionOut = '';
$codeBlockOn = false;
$lineCount = 0;
foreach ($descriptionLines as $descriptionLine) {
// Detect line of code: starting with 4 spaces,
// except lists which can start with +/*/- or `2.` after spaces.
$codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
// Detect and toggle block of code
if (!$codeBlockOn) {
$codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
} elseif (preg_match('/^```/', $descriptionLine) > 0) {
$codeBlockOn = false;
}
$hashtagTitle = ' title="Hashtag [^"]+"';
// Reverse `inline code` hashtags.
$descriptionLine = preg_replace(
'!(`[^`\n]*)<a href="[^ ]*"'. $hashtagTitle .'>([^<]+)</a>([^`\n]*`)!m',
'$1$2$3',
$descriptionLine
);
// Reverse all links in code blocks, only non hashtag elsewhere.
$hashtagFilter = (!$codeBlockOn && !$codeLineOn) ? '(?!'. $hashtagTitle .')': '(?:'. $hashtagTitle .')?';
$descriptionLine = preg_replace(
'#<a href="[^ ]*"'. $hashtagFilter .'>([^<]+)</a>#m',
'$1',
$descriptionLine
);
// Make hashtag links markdown ready, otherwise the links will be ignored with escape set to true
if (!$codeBlockOn && !$codeLineOn) {
$descriptionLine = preg_replace(
'#<a href="([^ ]*)"'. $hashtagTitle .'>([^<]+)</a>#m',
'[$2]($1)',
$descriptionLine
);
}
$descriptionOut .= $descriptionLine;
if ($lineCount++ < count($descriptionLines) - 1) {
$descriptionOut .= PHP_EOL;
}
}
return $descriptionOut;
}
/**
* Remove <br> tag to let markdown handle it.
*
* @param string $description input description text.
*
* @return string $description without <br> tags.
*/
function reverse_nl2br($description)
{
return preg_replace('!<br */?>!im', '', $description);
}
/**
* Remove HTML spaces '&nbsp;' auto generated by Shaarli core system.
*
* @param string $description input description text.
*
* @return string $description without HTML links.
*/
function reverse_space2nbsp($description)
{
return preg_replace('/(^| )&nbsp;/m', '$1 ', $description);
}
function reverse_feed_permalink($description)
{
return preg_replace('@&#8212; <a href="([^"]+)" title="[^"]+">([^<]+)</a>$@im', '&#8212; [$2]($1)', $description);
}
/**
* Replace not whitelisted protocols with http:// in given description.
*
* @param string $description input description text.
* @param array $allowedProtocols list of allowed protocols.
*
* @return string $description without malicious link.
*/
function filter_protocols($description, $allowedProtocols)
{
return preg_replace_callback(
'#]\((.*?)\)#is',
function ($match) use ($allowedProtocols) {
return ']('. whitelist_protocols($match[1], $allowedProtocols) .')';
},
$description
);
}
/**
* Remove dangerous HTML tags (tags, iframe, etc.).
* Doesn't affect <code> content (already escaped by Parsedown).
*
* @param string $description input description text.
*
* @return string given string escaped.
*/
function sanitize_html($description)
{
$escapeTags = array(
'script',
'style',
'link',
'iframe',
'frameset',
'frame',
);
foreach ($escapeTags as $tag) {
$description = preg_replace_callback(
'#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
function ($match) {
return escape($match[0]);
},
$description
);
}
$description = preg_replace(
'#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
'$1',
$description
);
return $description;
}
/**
* Render shaare contents through Markdown parser.
* 1. Remove HTML generated by Shaarli core.
* 2. Reverse the escape function.
* 3. Generate markdown descriptions.
* 4. Sanitize sensible HTML tags for security.
* 5. Wrap description in 'markdown' CSS class.
*
* @param string $description input description text.
* @param bool $escape escape HTML entities
*
* @return string HTML processed $description.
*/
function process_markdown($description, $escape = true, $allowedProtocols = [])
{
$parsedown = new Parsedown();
$processedDescription = $description;
$processedDescription = reverse_nl2br($processedDescription);
$processedDescription = reverse_space2nbsp($processedDescription);
$processedDescription = reverse_text2clickable($processedDescription);
$processedDescription = filter_protocols($processedDescription, $allowedProtocols);
$processedDescription = unescape($processedDescription);
$processedDescription = $parsedown
->setMarkupEscaped($escape)
->setBreaksEnabled(true)
->text($processedDescription);
$processedDescription = sanitize_html($processedDescription);
if (!empty($processedDescription)) {
$processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
}
return $processedDescription;
}
/**
* This function is never called, but contains translation calls for GNU gettext extraction.
*/
function markdown_dummy_translation()
{
// meta
t('Render shaare description with Markdown syntax.<br><strong>Warning</strong>:
If your shaared descriptions contained HTML tags before enabling the markdown plugin,
enabling it might break your page.
See the <a href="https://github.com/shaarli/Shaarli/tree/master/plugins/markdown#html-rendering">README</a>.');
}

View file

@ -68,6 +68,28 @@
</select> </select>
</div> </div>
</div> </div>
<div class="pure-u-lg-{$ratioLabel} pure-u-1">
<div class="form-label">
<label for="formatter">
<span class="label-name">{'Description formatter'|t}</span>
</label>
</div>
</div>
<div class="pure-u-lg-{$ratioInput} pure-u-1">
<div class="form-input">
<select name="formatter" id="formatter" class="align">
{loop="$formatter_available"}
<option value="{$value}"
{if="$value===$formatter"}
selected="selected"
{/if}
>
{$value|ucfirst}
</option>
{/loop}
</select>
</div>
</div>
</div> </div>
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-lg-{$ratioLabel} pure-u-1"> <div class="pure-u-lg-{$ratioLabel} pure-u-1">

View file

@ -11,7 +11,6 @@
<h2 class="window-title"> <h2 class="window-title">
{if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if} {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
</h2> </h2>
<input type="hidden" name="lf_linkdate" value="{$link.linkdate}">
{if="isset($link.id)"} {if="isset($link.id)"}
<input type="hidden" name="lf_id" value="{$link.id}"> <input type="hidden" name="lf_id" value="{$link.id}">
{/if} {/if}
@ -20,7 +19,7 @@
<label for="lf_url">{'URL'|t}</label> <label for="lf_url">{'URL'|t}</label>
</div> </div>
<div> <div>
<input type="text" name="lf_url" id="lf_url" value="{$link.url}" class="lf_input autofocus"> <input type="text" name="lf_url" id="lf_url" value="{$link.url}" class="lf_input">
</div> </div>
<div> <div>
<label for="lf_title">{'Title'|t}</label> <label for="lf_title">{'Title'|t}</label>
@ -50,6 +49,15 @@
&nbsp;<label for="lf_private">{'Private'|t}</label> &nbsp;<label for="lf_private">{'Private'|t}</label>
</div> </div>
{if="$formatter==='markdown'"}
<div class="md_help">
{'Description will be rendered with'|t}
<a href="http://daringfireball.net/projects/markdown/syntax" title="{'Markdown syntax documentation'|t}">
{'Markdown syntax'|t}
</a>.
</div>
{/if}
<div id="editlink-plugins"> <div id="editlink-plugins">
{loop="$edit_link_plugin"} {loop="$edit_link_plugin"}
{$value} {$value}

View file

@ -8,6 +8,9 @@
<link href="img/favicon.png" rel="shortcut icon" type="image/png" /> <link href="img/favicon.png" rel="shortcut icon" type="image/png" />
<link href="img/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180" /> <link href="img/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180" />
<link type="text/css" rel="stylesheet" href="css/shaarli.min.css?v={$version_hash}" /> <link type="text/css" rel="stylesheet" href="css/shaarli.min.css?v={$version_hash}" />
{if="$formatter==='markdown'"}
<link type="text/css" rel="stylesheet" href="css/markdown.min.css?v={$version_hash}" />
{/if}
{loop="$plugins_includes.css_files"} {loop="$plugins_includes.css_files"}
<link type="text/css" rel="stylesheet" href="{$value}?v={$version_hash}#"/> <link type="text/css" rel="stylesheet" href="{$value}?v={$version_hash}#"/>
{/loop} {/loop}

View file

@ -32,6 +32,19 @@
</td> </td>
</tr> </tr>
<tr>
<td><b>Description formatter:</b></td>
<td>
<select name="formatter" id="formatter">
{loop="$formatter_available"}
<option value="{$value}" {if="$value===$formatter"}selected{/if}>
{$value|ucfirst}
</option>
{/loop}
</select>
</td>
</tr>
<tr> <tr>
<td><b>Timezone:</b></td> <td><b>Timezone:</b></td>
<td> <td>

View file

@ -26,7 +26,16 @@
<input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input" <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input"
data-list="{loop="$tags"}{$key}, {/loop}" data-multiple autocomplete="off" ><br> data-list="{loop="$tags"}{$key}, {/loop}" data-multiple autocomplete="off" ><br>
{loop="$edit_link_plugin"} {if="$formatter==='markdown'"}
<div class="md_help">
{'Description will be rendered with'|t}
<a href="http://daringfireball.net/projects/markdown/syntax" title="{'Markdown syntax documentation'|t}">
{'Markdown syntax'|t}
</a>.
</div>
{/if}
{loop="$edit_link_plugin"}
{$value} {$value}
{/loop} {/loop}
@ -38,7 +47,6 @@
&nbsp;<label for="lf_private"><i>Private</i></label><br><br> &nbsp;<label for="lf_private"><i>Private</i></label><br><br>
{/if} {/if}
<input type="submit" value="Save" name="save_edit" class="bigbutton"> <input type="submit" value="Save" name="save_edit" class="bigbutton">
<input type="submit" value="Cancel" name="cancel_edit" class="bigbutton">
{if="!$link_is_new && isset($link.id)"} {if="!$link_is_new && isset($link.id)"}
<a href="?delete_link&amp;lf_linkdate={$link.id}&amp;token={$token}" <a href="?delete_link&amp;lf_linkdate={$link.id}&amp;token={$token}"
name="delete_link" class="bigbutton" name="delete_link" class="bigbutton"

View file

@ -7,6 +7,9 @@
<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" /> <link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
<link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" /> <link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" />
<link type="text/css" rel="stylesheet" href="css/shaarli.min.css" /> <link type="text/css" rel="stylesheet" href="css/shaarli.min.css" />
{if="$formatter==='markdown'"}
<link type="text/css" rel="stylesheet" href="css/markdown.min.css?v={$version_hash}" />
{/if}
{loop="$plugins_includes.css_files"} {loop="$plugins_includes.css_files"}
<link type="text/css" rel="stylesheet" href="{$value}#"/> <link type="text/css" rel="stylesheet" href="{$value}#"/>
{/loop} {/loop}

View file

@ -30,6 +30,7 @@ module.exports = [
'./assets/default/js/base.js', './assets/default/js/base.js',
'./assets/default/scss/shaarli.scss', './assets/default/scss/shaarli.scss',
].concat(glob.sync('./assets/default/img/*')), ].concat(glob.sync('./assets/default/img/*')),
markdown: './assets/common/css/markdown.css',
}, },
output: { output: {
filename: '[name].min.js', filename: '[name].min.js',
@ -50,7 +51,7 @@ module.exports = [
} }
}, },
{ {
test: /\.scss/, test: /\.s?css/,
use: extractCssDefault.extract({ use: extractCssDefault.extract({
use: [{ use: [{
loader: "css-loader", loader: "css-loader",
@ -97,6 +98,7 @@ module.exports = [
'./assets/vintage/css/reset.css', './assets/vintage/css/reset.css',
'./assets/vintage/css/shaarli.css', './assets/vintage/css/shaarli.css',
].concat(glob.sync('./assets/vintage/img/*')), ].concat(glob.sync('./assets/vintage/img/*')),
markdown: './assets/common/css/markdown.css',
thumbnails: './assets/common/js/thumbnails.js', thumbnails: './assets/common/js/thumbnails.js',
thumbnails_update: './assets/common/js/thumbnails-update.js', thumbnails_update: './assets/common/js/thumbnails-update.js',
}, },