diff --git a/application/Utils.php b/application/Utils.php index 4e97cdda..72c90049 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -294,15 +294,15 @@ function normalize_spaces($string) * Requires php-intl to display international datetimes, * otherwise default format '%c' will be returned. * - * @param DateTime $date to format. - * @param bool $time Displays time if true. - * @param bool $intl Use international format if true. + * @param DateTimeInterface $date to format. + * @param bool $time Displays time if true. + * @param bool $intl Use international format if true. * * @return bool|string Formatted date, or false if the input is invalid. */ function format_date($date, $time = true, $intl = true) { - if (! $date instanceof DateTime) { + if (! $date instanceof DateTimeInterface) { return false; } diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php index fd556679..797a36b8 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php @@ -436,7 +436,7 @@ public function filterDay($day) throw new Exception('Invalid date format'); } - $filtered = array(); + $filtered = []; foreach ($this->bookmarks as $key => $l) { if ($l->getCreated()->format('Ymd') == $day) { $filtered[$key] = $l; diff --git a/application/front/controllers/DailyController.php b/application/front/controllers/DailyController.php new file mode 100644 index 00000000..c2fdaa55 --- /dev/null +++ b/application/front/controllers/DailyController.php @@ -0,0 +1,142 @@ +getQueryParam('day') ?? date('Ymd'); + + $availableDates = $this->container->bookmarkService->days(); + $nbAvailableDates = count($availableDates); + $index = array_search($day, $availableDates); + + if ($index === false && $nbAvailableDates > 0) { + // no bookmarks for day, but at least one day with bookmarks + $index = $nbAvailableDates - 1; + $day = $availableDates[$index]; + } + + if ($day === date('Ymd')) { + $this->assignView('dayDesc', t('Today')); + } elseif ($day === date('Ymd', strtotime('-1 days'))) { + $this->assignView('dayDesc', t('Yesterday')); + } + + if ($index !== false) { + if ($index >= 1) { + $previousDay = $availableDates[$index - 1]; + } + if ($index < $nbAvailableDates - 1) { + $nextDay = $availableDates[$index + 1]; + } + } + + try { + $linksToDisplay = $this->container->bookmarkService->filterDay($day); + } catch (\Exception $exc) { + $linksToDisplay = []; + } + + $formatter = $this->container->formatterFactory->getFormatter(); + // We pre-format some fields for proper output. + foreach ($linksToDisplay as $key => $bookmark) { + $linksToDisplay[$key] = $formatter->format($bookmark); + // This page is a bit specific, we need raw description to calculate the length + $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; + $linksToDisplay[$key]['description'] = $bookmark->getDescription(); + } + + $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); + $data = [ + 'linksToDisplay' => $linksToDisplay, + 'day' => $dayDate->getTimestamp(), + 'dayDate' => $dayDate, + 'previousday' => $previousDay ?? '', + 'nextday' => $nextDay ?? '', + ]; + + // Hooks are called before column construction so that plugins don't have to deal with columns. + $this->executeHooks($data); + + $data['cols'] = $this->calculateColumns($data['linksToDisplay']); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); + $this->assignView( + 'pagetitle', + t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle + ); + + return $response->write($this->render('daily')); + } + + /** + * We need to spread the articles on 3 columns. + * did not want to use a JavaScript lib like http://masonry.desandro.com/ + * so I manually spread entries with a simple method: I roughly evaluate the + * height of a div according to title and description length. + */ + protected function calculateColumns(array $links): array + { + // Entries to display, for each column. + $columns = [[], [], []]; + // Rough estimate of columns fill. + $fill = [0, 0, 0]; + foreach ($links as $link) { + // Roughly estimate length of entry (by counting characters) + // Title: 30 chars = 1 line. 1 line is 30 pixels height. + // Description: 836 characters gives roughly 342 pixel height. + // This is not perfect, but it's usually OK. + $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836; + if (! empty($link['thumbnail'])) { + $length += 100; // 1 thumbnails roughly takes 100 pixels height. + } + // Then put in column which is the less filled: + $smallest = min($fill); // find smallest value in array. + $index = array_search($smallest, $fill); // find index of this smallest value. + array_push($columns[$index], $link); // Put entry in this column. + $fill[$index] += $length; + } + + return $columns; + } + + /** + * @param mixed[] $data Variables passed to the template engine + * + * @return mixed[] Template data after active plugins render_picwall hook execution. + */ + protected function executeHooks(array $data): array + { + $this->container->pluginManager->executeHooks( + 'render_daily', + $data, + ['loggedin' => $this->container->loginManager->isLoggedIn()] + ); + + return $data; + } +} diff --git a/index.php b/index.php index 89a1e581..f8337d79 100644 --- a/index.php +++ b/index.php @@ -398,112 +398,6 @@ function showDailyRSS($bookmarkService, $conf, $loginManager) exit; } -/** - * Show the 'Daily' page. - * - * @param PageBuilder $pageBuilder Template engine wrapper. - * @param BookmarkServiceInterface $bookmarkService instance. - * @param ConfigManager $conf Configuration Manager instance. - * @param PluginManager $pluginManager Plugin Manager instance. - * @param LoginManager $loginManager Login Manager instance - */ -function showDaily($pageBuilder, $bookmarkService, $conf, $pluginManager, $loginManager) -{ - if (isset($_GET['day'])) { - $day = $_GET['day']; - if ($day === date('Ymd', strtotime('now'))) { - $pageBuilder->assign('dayDesc', t('Today')); - } elseif ($day === date('Ymd', strtotime('-1 days'))) { - $pageBuilder->assign('dayDesc', t('Yesterday')); - } - } else { - $day = date('Ymd', strtotime('now')); // Today, in format YYYYMMDD. - $pageBuilder->assign('dayDesc', t('Today')); - } - - $days = $bookmarkService->days(); - $i = array_search($day, $days); - if ($i === false && count($days)) { - // no bookmarks for day, but at least one day with bookmarks - $i = count($days) - 1; - $day = $days[$i]; - } - $previousday = ''; - $nextday = ''; - - if ($i !== false) { - if ($i >= 1) { - $previousday = $days[$i - 1]; - } - if ($i < count($days) - 1) { - $nextday = $days[$i + 1]; - } - } - try { - $linksToDisplay = $bookmarkService->filterDay($day); - } catch (Exception $exc) { - error_log($exc); - $linksToDisplay = []; - } - - $factory = new FormatterFactory($conf, $loginManager->isLoggedIn()); - $formatter = $factory->getFormatter(); - // We pre-format some fields for proper output. - foreach ($linksToDisplay as $key => $bookmark) { - $linksToDisplay[$key] = $formatter->format($bookmark); - // This page is a bit specific, we need raw description to calculate the length - $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; - $linksToDisplay[$key]['description'] = $bookmark->getDescription(); - } - - $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); - $data = array( - 'pagetitle' => $conf->get('general.title') .' - '. format_date($dayDate, false), - 'linksToDisplay' => $linksToDisplay, - 'day' => $dayDate->getTimestamp(), - 'dayDate' => $dayDate, - 'previousday' => $previousday, - 'nextday' => $nextday, - ); - - /* Hook is called before column construction so that plugins don't have - to deal with columns. */ - $pluginManager->executeHooks('render_daily', $data, array('loggedin' => $loginManager->isLoggedIn())); - - /* We need to spread the articles on 3 columns. - I did not want to use a JavaScript lib like http://masonry.desandro.com/ - so I manually spread entries with a simple method: I roughly evaluate the - height of a div according to title and description length. - */ - $columns = array(array(), array(), array()); // Entries to display, for each column. - $fill = array(0, 0, 0); // Rough estimate of columns fill. - foreach ($data['linksToDisplay'] as $key => $bookmark) { - // Roughly estimate length of entry (by counting characters) - // Title: 30 chars = 1 line. 1 line is 30 pixels height. - // Description: 836 characters gives roughly 342 pixel height. - // This is not perfect, but it's usually OK. - $length = strlen($bookmark['title']) + (342 * strlen($bookmark['description'])) / 836; - if (! empty($bookmark['thumbnail'])) { - $length += 100; // 1 thumbnails roughly takes 100 pixels height. - } - // Then put in column which is the less filled: - $smallest = min($fill); // find smallest value in array. - $index = array_search($smallest, $fill); // find index of this smallest value. - array_push($columns[$index], $bookmark); // Put entry in this column. - $fill[$index] += $length; - } - - $data['cols'] = $columns; - - foreach ($data as $key => $value) { - $pageBuilder->assign($key, $value); - } - - $pageBuilder->assign('pagetitle', t('Daily') .' - '. $conf->get('general.title', 'Shaarli')); - $pageBuilder->renderPage('daily'); - exit; -} - /** * Renders the linklist * @@ -628,7 +522,8 @@ function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionM // Daily page. if ($targetPage == Router::$PAGE_DAILY) { - showDaily($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager); + header('Location: ./daily'); + exit; } // ATOM and RSS feed. @@ -1850,6 +1745,8 @@ function install($conf, $sessionManager, $loginManager) $this->get('/picture-wall', '\Shaarli\Front\Controller\PictureWallController:index')->setName('picwall'); $this->get('/tag-cloud', '\Shaarli\Front\Controller\TagCloudController:cloud')->setName('tagcloud'); $this->get('/tag-list', '\Shaarli\Front\Controller\TagCloudController:list')->setName('taglist'); + $this->get('/daily', '\Shaarli\Front\Controller\DailyController:index')->setName('daily'); + $this->get('/add-tag/{newTag}', '\Shaarli\Front\Controller\TagController:addTag')->setName('add-tag'); })->add('\Shaarli\Front\ShaarliMiddleware'); diff --git a/tests/front/controller/DailyControllerTest.php b/tests/front/controller/DailyControllerTest.php new file mode 100644 index 00000000..bb453d5b --- /dev/null +++ b/tests/front/controller/DailyControllerTest.php @@ -0,0 +1,423 @@ +container = $this->createMock(ShaarliContainer::class); + $this->controller = new DailyController($this->container); + } + + public function testValidControllerInvokeDefault(): void + { + $this->createValidContainerMockSet(); + + $currentDay = new \DateTimeImmutable('2020-05-13'); + + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->willReturn($currentDay->format('Ymd')); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + // Links dataset: 2 links with thumbnails + $this->container->bookmarkService + ->expects(static::once()) + ->method('days') + ->willReturnCallback(function () use ($currentDay): array { + return [ + '20200510', + $currentDay->format('Ymd'), + '20200516', + ]; + }) + ; + $this->container->bookmarkService + ->expects(static::once()) + ->method('filterDay') + ->willReturnCallback(function (): array { + return [ + (new Bookmark()) + ->setId(1) + ->setUrl('http://url.tld') + ->setTitle(static::generateContent(50)) + ->setDescription(static::generateContent(500)) + , + (new Bookmark()) + ->setId(2) + ->setUrl('http://url2.tld') + ->setTitle(static::generateContent(50)) + ->setDescription(static::generateContent(500)) + , + (new Bookmark()) + ->setId(3) + ->setUrl('http://url3.tld') + ->setTitle(static::generateContent(50)) + ->setDescription(static::generateContent(500)) + , + ]; + }) + ; + + // Make sure that PluginManager hook is triggered + $this->container->pluginManager + ->expects(static::at(0)) + ->method('executeHooks') + ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array { + static::assertSame('render_daily', $hook); + + static::assertArrayHasKey('linksToDisplay', $data); + static::assertCount(3, $data['linksToDisplay']); + static::assertSame(1, $data['linksToDisplay'][0]['id']); + static::assertSame($currentDay->getTimestamp(), $data['day']); + static::assertSame('20200510', $data['previousday']); + static::assertSame('20200516', $data['nextday']); + + static::assertArrayHasKey('loggedin', $param); + + return $data; + }); + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('daily', (string) $result->getBody()); + static::assertSame( + 'Daily - '. format_date($currentDay, false, true) .' - Shaarli', + $assignedVariables['pagetitle'] + ); + static::assertCount(3, $assignedVariables['linksToDisplay']); + + $link = $assignedVariables['linksToDisplay'][0]; + + static::assertSame(1, $link['id']); + static::assertSame('http://url.tld', $link['url']); + static::assertNotEmpty($link['title']); + static::assertNotEmpty($link['description']); + static::assertNotEmpty($link['formatedDescription']); + + $link = $assignedVariables['linksToDisplay'][1]; + + static::assertSame(2, $link['id']); + static::assertSame('http://url2.tld', $link['url']); + static::assertNotEmpty($link['title']); + static::assertNotEmpty($link['description']); + static::assertNotEmpty($link['formatedDescription']); + + $link = $assignedVariables['linksToDisplay'][2]; + + static::assertSame(3, $link['id']); + static::assertSame('http://url3.tld', $link['url']); + static::assertNotEmpty($link['title']); + static::assertNotEmpty($link['description']); + static::assertNotEmpty($link['formatedDescription']); + + static::assertCount(3, $assignedVariables['cols']); + static::assertCount(1, $assignedVariables['cols'][0]); + static::assertCount(1, $assignedVariables['cols'][1]); + static::assertCount(1, $assignedVariables['cols'][2]); + + $link = $assignedVariables['cols'][0][0]; + + static::assertSame(1, $link['id']); + static::assertSame('http://url.tld', $link['url']); + static::assertNotEmpty($link['title']); + static::assertNotEmpty($link['description']); + static::assertNotEmpty($link['formatedDescription']); + + $link = $assignedVariables['cols'][1][0]; + + static::assertSame(2, $link['id']); + static::assertSame('http://url2.tld', $link['url']); + static::assertNotEmpty($link['title']); + static::assertNotEmpty($link['description']); + static::assertNotEmpty($link['formatedDescription']); + + $link = $assignedVariables['cols'][2][0]; + + static::assertSame(3, $link['id']); + static::assertSame('http://url3.tld', $link['url']); + static::assertNotEmpty($link['title']); + static::assertNotEmpty($link['description']); + static::assertNotEmpty($link['formatedDescription']); + } + + /** + * Daily page - test that everything goes fine with no future or past bookmarks + */ + public function testValidControllerInvokeNoFutureOrPast(): void + { + $this->createValidContainerMockSet(); + + $currentDay = new \DateTimeImmutable('2020-05-13'); + + $request = $this->createMock(Request::class); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + // Links dataset: 2 links with thumbnails + $this->container->bookmarkService + ->expects(static::once()) + ->method('days') + ->willReturnCallback(function () use ($currentDay): array { + return [ + $currentDay->format($currentDay->format('Ymd')), + ]; + }) + ; + $this->container->bookmarkService + ->expects(static::once()) + ->method('filterDay') + ->willReturnCallback(function (): array { + return [ + (new Bookmark()) + ->setId(1) + ->setUrl('http://url.tld') + ->setTitle(static::generateContent(50)) + ->setDescription(static::generateContent(500)) + , + ]; + }) + ; + + // Make sure that PluginManager hook is triggered + $this->container->pluginManager + ->expects(static::at(0)) + ->method('executeHooks') + ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array { + static::assertSame('render_daily', $hook); + + static::assertArrayHasKey('linksToDisplay', $data); + static::assertCount(1, $data['linksToDisplay']); + static::assertSame(1, $data['linksToDisplay'][0]['id']); + static::assertSame($currentDay->getTimestamp(), $data['day']); + static::assertEmpty($data['previousday']); + static::assertEmpty($data['nextday']); + + static::assertArrayHasKey('loggedin', $param); + + return $data; + }); + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('daily', (string) $result->getBody()); + static::assertSame( + 'Daily - '. format_date($currentDay, false, true) .' - Shaarli', + $assignedVariables['pagetitle'] + ); + static::assertCount(1, $assignedVariables['linksToDisplay']); + + $link = $assignedVariables['linksToDisplay'][0]; + static::assertSame(1, $link['id']); + } + + /** + * Daily page - test that height adjustment in columns is working + */ + public function testValidControllerInvokeHeightAdjustment(): void + { + $this->createValidContainerMockSet(); + + $currentDay = new \DateTimeImmutable('2020-05-13'); + + $request = $this->createMock(Request::class); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + // Links dataset: 2 links with thumbnails + $this->container->bookmarkService + ->expects(static::once()) + ->method('days') + ->willReturnCallback(function () use ($currentDay): array { + return [ + $currentDay->format($currentDay->format('Ymd')), + ]; + }) + ; + $this->container->bookmarkService + ->expects(static::once()) + ->method('filterDay') + ->willReturnCallback(function (): array { + return [ + (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'), + (new Bookmark()) + ->setId(2) + ->setUrl('http://url.tld') + ->setTitle(static::generateContent(50)) + ->setDescription(static::generateContent(5000)) + , + (new Bookmark())->setId(3)->setUrl('http://url.tld')->setTitle('title'), + (new Bookmark())->setId(4)->setUrl('http://url.tld')->setTitle('title'), + (new Bookmark())->setId(5)->setUrl('http://url.tld')->setTitle('title'), + (new Bookmark())->setId(6)->setUrl('http://url.tld')->setTitle('title'), + (new Bookmark())->setId(7)->setUrl('http://url.tld')->setTitle('title'), + ]; + }) + ; + + // Make sure that PluginManager hook is triggered + $this->container->pluginManager + ->expects(static::at(0)) + ->method('executeHooks') + ->willReturnCallback(function (string $hook, array $data, array $param): array { + return $data; + }) + ; + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('daily', (string) $result->getBody()); + static::assertCount(7, $assignedVariables['linksToDisplay']); + + $columnIds = function (array $column): array { + return array_map(function (array $item): int { return $item['id']; }, $column); + }; + + static::assertSame([1, 4, 6], $columnIds($assignedVariables['cols'][0])); + static::assertSame([2], $columnIds($assignedVariables['cols'][1])); + static::assertSame([3, 5, 7], $columnIds($assignedVariables['cols'][2])); + } + + /** + * Daily page - no bookmark + */ + public function testValidControllerInvokeNoBookmark(): void + { + $this->createValidContainerMockSet(); + + $request = $this->createMock(Request::class); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + // Links dataset: 2 links with thumbnails + $this->container->bookmarkService + ->expects(static::once()) + ->method('days') + ->willReturnCallback(function (): array { + return []; + }) + ; + $this->container->bookmarkService + ->expects(static::once()) + ->method('filterDay') + ->willReturnCallback(function (): array { + return []; + }) + ; + + // Make sure that PluginManager hook is triggered + $this->container->pluginManager + ->expects(static::at(0)) + ->method('executeHooks') + ->willReturnCallback(function (string $hook, array $data, array $param): array { + return $data; + }) + ; + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('daily', (string) $result->getBody()); + static::assertCount(0, $assignedVariables['linksToDisplay']); + static::assertSame('Today', $assignedVariables['dayDesc']); + } + + protected function createValidContainerMockSet(): void + { + $loginManager = $this->createMock(LoginManager::class); + $this->container->loginManager = $loginManager; + + // Config + $conf = $this->createMock(ConfigManager::class); + $this->container->conf = $conf; + $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) { + return $default; + }); + + // PageBuilder + $pageBuilder = $this->createMock(PageBuilder::class); + $pageBuilder + ->method('render') + ->willReturnCallback(function (string $template): string { + return $template; + }) + ; + $this->container->pageBuilder = $pageBuilder; + + // Plugin Manager + $pluginManager = $this->createMock(PluginManager::class); + $this->container->pluginManager = $pluginManager; + + // BookmarkService + $bookmarkService = $this->createMock(BookmarkServiceInterface::class); + $this->container->bookmarkService = $bookmarkService; + + // Formatter + $formatterFactory = $this->createMock(FormatterFactory::class); + $formatterFactory + ->method('getFormatter') + ->willReturnCallback(function (): BookmarkFormatter { + return new BookmarkRawFormatter($this->container->conf, true); + }) + ; + $this->container->formatterFactory = $formatterFactory; + } + + protected function assignTemplateVars(array &$variables): void + { + $this->container->pageBuilder + ->expects(static::atLeastOnce()) + ->method('assign') + ->willReturnCallback(function ($key, $value) use (&$variables) { + $variables[$key] = $value; + + return $this; + }) + ; + } + + protected static function generateContent(int $length): string + { + // bin2hex(random_bytes) generates string twice as long as given parameter + $length = (int) ceil($length / 2); + return bin2hex(random_bytes($length)); + } +} diff --git a/tpl/default/daily.html b/tpl/default/daily.html index e2e7b47b..f07c0a8b 100644 --- a/tpl/default/daily.html +++ b/tpl/default/daily.html @@ -25,7 +25,7 @@

{if="$previousday"} - + {'Previous day'|t} @@ -36,7 +36,7 @@

{if="$nextday"} - + {'Next day'|t} @@ -69,7 +69,7 @@

{$link=$value}
- + {$link.title}