4e3875c0ce
How it works: 1. when a fulltext search is made, Shaarli looks for the first occurence position of every term matching the search. No change here, but we store these positions in an array, in Bookmark's additionalContent. 2. when formatting bookmarks (through BookmarkFormatter implementation): 1. first we insert specific tokens at every search result positions 2. we format the content (escape HTML, apply markdown, etc.) 3. as a last step, we replace our token with displayable span elements Cons: this tightens coupling between search filters and formatters Pros: it was absolutely necessary not to perform the search twice. this solution has close to no impact on performances. Fixes #205
209 lines
5.7 KiB
PHP
209 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace Shaarli\Formatter;
|
|
|
|
/**
|
|
* Class BookmarkDefaultFormatter
|
|
*
|
|
* Default bookmark formatter.
|
|
* Escape values for HTML display and automatically add link to URL and hashtags.
|
|
*
|
|
* @package Shaarli\Formatter
|
|
*/
|
|
class BookmarkDefaultFormatter extends BookmarkFormatter
|
|
{
|
|
const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
|
|
const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatTitle($bookmark)
|
|
{
|
|
return escape($bookmark->getTitle());
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatTitleHtml($bookmark)
|
|
{
|
|
$title = $this->tokenizeSearchHighlightField(
|
|
$bookmark->getTitle() ?? '',
|
|
$bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
|
|
);
|
|
|
|
return $this->replaceTokens(escape($title));
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatDescription($bookmark)
|
|
{
|
|
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
|
$description = $this->tokenizeSearchHighlightField(
|
|
$bookmark->getDescription() ?? '',
|
|
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
|
|
);
|
|
|
|
return $this->replaceTokens(format_description(escape($description), $indexUrl));
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatTagList($bookmark)
|
|
{
|
|
return escape(parent::formatTagList($bookmark));
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatTagListHtml($bookmark)
|
|
{
|
|
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
|
|
return $this->formatTagList($bookmark);
|
|
}
|
|
|
|
$tags = $this->tokenizeSearchHighlightField(
|
|
$bookmark->getTagsString(),
|
|
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
|
|
);
|
|
$tags = $this->filterTagList(explode(' ', $tags));
|
|
$tags = escape($tags);
|
|
$tags = $this->replaceTokensArray($tags);
|
|
|
|
return $tags;
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatTagString($bookmark)
|
|
{
|
|
return implode(' ', $this->formatTagList($bookmark));
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatUrl($bookmark)
|
|
{
|
|
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
|
|
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
|
|
}
|
|
|
|
return escape($bookmark->getUrl());
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatRealUrl($bookmark)
|
|
{
|
|
if ($bookmark->isNote()) {
|
|
if (isset($this->contextData['index_url'])) {
|
|
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
|
|
}
|
|
|
|
if (isset($this->contextData['base_path'])) {
|
|
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
|
|
}
|
|
|
|
return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
|
|
}
|
|
|
|
return escape($bookmark->getUrl());
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatUrlHtml($bookmark)
|
|
{
|
|
$url = $this->tokenizeSearchHighlightField(
|
|
$bookmark->getUrl() ?? '',
|
|
$bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
|
|
);
|
|
|
|
return $this->replaceTokens(escape($url));
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
protected function formatThumbnail($bookmark)
|
|
{
|
|
return escape($bookmark->getThumbnail());
|
|
}
|
|
|
|
/**
|
|
* Insert search highlight token in provided field content based on a list of search result positions
|
|
*
|
|
* @param string $fieldContent
|
|
* @param array|null $positions List of of search results with 'start' and 'end' positions.
|
|
*
|
|
* @return string Updated $fieldContent.
|
|
*/
|
|
protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
|
|
{
|
|
if (empty($positions)) {
|
|
return $fieldContent;
|
|
}
|
|
|
|
$insertedTokens = 0;
|
|
$tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
|
|
foreach ($positions as $position) {
|
|
$position = [
|
|
'start' => $position['start'] + ($insertedTokens * $tokenLength),
|
|
'end' => $position['end'] + ($insertedTokens * $tokenLength),
|
|
];
|
|
|
|
$content = mb_substr($fieldContent, 0, $position['start']);
|
|
$content .= static::SEARCH_HIGHLIGHT_OPEN;
|
|
$content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
|
|
$content .= static::SEARCH_HIGHLIGHT_CLOSE;
|
|
$content .= mb_substr($fieldContent, $position['end']);
|
|
|
|
$fieldContent = $content;
|
|
|
|
$insertedTokens += 2;
|
|
}
|
|
|
|
return $fieldContent;
|
|
}
|
|
|
|
/**
|
|
* Replace search highlight tokens with HTML highlighted span.
|
|
*
|
|
* @param string $fieldContent
|
|
*
|
|
* @return string updated content.
|
|
*/
|
|
protected function replaceTokens(string $fieldContent): string
|
|
{
|
|
return str_replace(
|
|
[static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
|
|
['<span class="search-highlight">', '</span>'],
|
|
$fieldContent
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Apply replaceTokens to an array of content strings.
|
|
*
|
|
* @param string[] $fieldContents
|
|
*
|
|
* @return array
|
|
*/
|
|
protected function replaceTokensArray(array $fieldContents): array
|
|
{
|
|
foreach ($fieldContents as &$entry) {
|
|
$entry = $this->replaceTokens($entry);
|
|
}
|
|
|
|
return $fieldContents;
|
|
}
|
|
}
|