Fix unified search

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-08-03 12:54:37 +02:00 committed by npmbuildbot[bot]
parent 4987fe9a51
commit 1a1b3e20e4
39 changed files with 675 additions and 80 deletions

View File

@ -28,6 +28,7 @@ namespace OCA\Comments\Search;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Search\IProvider;
use OCP\Search\ISearchQuery;
use OCP\Search\SearchResult;
@ -36,6 +37,9 @@ use function pathinfo;
class Provider implements IProvider {
/** @var IUserManager */
private $userManager;
/** @var IL10N */
private $l10n;
@ -45,9 +49,11 @@ class Provider implements IProvider {
/** @var LegacyProvider */
private $legacyProvider;
public function __construct(IL10N $l10n,
public function __construct(IUserManager $userManager,
IL10N $l10n,
IURLGenerator $urlGenerator,
LegacyProvider $legacyProvider) {
$this->userManager = $userManager;
$this->l10n = $l10n;
$this->urlGenerator = $urlGenerator;
$this->legacyProvider = $legacyProvider;
@ -57,23 +63,30 @@ class Provider implements IProvider {
return 'comments';
}
public function getName(): string {
return $this->l10n->t('Comments');
}
public function search(IUser $user, ISearchQuery $query): SearchResult {
return SearchResult::complete(
$this->l10n->t('Comments'),
array_map(function (Result $result) {
$path = $result->path;
$pathInfo = pathinfo($path);
$isUser = $this->userManager->userExists($result->authorId);
$avatarUrl = $isUser
? $this->urlGenerator->linkToRoute('core.avatar.getAvatar', ['userId' => $result->authorId, 'size' => 42])
: $this->urlGenerator->linkToRoute('core.GuestAvatar.getAvatar', ['guestName' => $result->authorId, 'size' => 42]);
return new CommentsSearchResultEntry(
$this->urlGenerator->linkToRoute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->id]),
$avatarUrl,
$result->name,
$path,
$this->urlGenerator->linkToRoute(
'files.view.index',
[
$this->urlGenerator->linkToRoute('files.view.index',[
'dir' => $pathInfo['dirname'],
'scrollto' => $pathInfo['basename'],
]
)
]),
'',
true
);
}, $this->legacyProvider->search($query->getTerm()))
);

View File

@ -57,17 +57,43 @@ class FilesSearchProvider implements IProvider {
return 'files';
}
public function getName(): string {
return $this->l10n->t('Files');
}
public function search(IUser $user, ISearchQuery $query): SearchResult {
return SearchResult::complete(
$this->l10n->t('Files'),
array_map(function (FileResult $result) {
// Generate thumbnail url
$thumbnailUrl = $result->type === 'folder'
? ''
: $this->urlGenerator->linkToRoute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->id]);
return new FilesSearchResultEntry(
$this->urlGenerator->linkToRoute('core.Preview.getPreviewByFileId', ['x' => 32, 'y' => 32, 'fileId' => $result->id]),
$thumbnailUrl,
$result->name,
$result->path,
$result->link
$this->formatSubline($result),
$result->link,
$result->type === 'folder' ? 'icon-folder' : 'icon-filetype-file'
);
}, $this->fileSearch->search($query->getTerm()))
);
}
/**
* Format subline for files
*
* @param FileResult $result
* @return string
*/
private function formatSubline($result): string {
// Do not show the location if the file is in root
if ($result->path === '/' . $result->name) {
return '';
}
$path = ltrim(dirname($result->path), '/');
return $this->l10n->t('in %s', [$path]);
}
}

View File

@ -31,7 +31,8 @@ class FilesSearchResultEntry extends ASearchResultEntry {
public function __construct(string $thumbnailUrl,
string $filename,
string $path,
string $url) {
parent::__construct($thumbnailUrl, $filename, $path, $url);
string $url,
string $icon) {
parent::__construct($thumbnailUrl, $filename, $path, $url, $icon, false);
}
}

View File

@ -78,7 +78,7 @@ class UnifiedSearchController extends Controller {
?int $sortOrder = null,
?int $limit = null,
$cursor = null): JSONResponse {
if (empty($term)) {
if (empty(trim($term))) {
return new JSONResponse(null, Http::STATUS_BAD_REQUEST);
}

View File

@ -46,4 +46,6 @@
--animation-quick: $animation-quick;
--animation-slow: $animation-slow;
--header-height: $header-height;
}

View File

@ -384,20 +384,20 @@ audio, canvas, embed, iframe, img, input, object, video {
.icon-file,
.icon-filetype-text {
@include icon-color('text', 'filetypes', $color-black, 1, true);
@include icon-color('text', 'filetypes', #969696, 1, true);
}
.icon-filetype-file {
@include icon-color('file', 'filetypes', $color-black, 1, true);
@include icon-color('file', 'filetypes', #969696, 1, true);
}
@include icon-black-white('folder', 'filetypes', 1, true);
.icon-filetype-folder {
@include icon-color('folder', 'filetypes', $color-black, 1, true);
@include icon-color('folder', 'filetypes', $color-primary, 1, true);
}
.icon-filetype-folder-drag-accept {
@include icon-color('folder-drag-accept', 'filetypes', $color-black, 1, true);
@include icon-color('folder-drag-accept', 'filetypes', $color-primary, 1, true);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
core/js/dist/unified-search.js vendored Normal file

File diff suppressed because one or more lines are too long

1
core/js/dist/unified-search.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,206 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-click-outside="closeMenu" :class="{ 'header-menu--opened': opened }" class="header-menu">
<a class="header-menu__trigger"
href="#"
:aria-controls="`header-menu-${id}`"
:aria-expanded="opened"
aria-haspopup="true"
@click.prevent="toggleMenu">
<slot name="trigger" />
</a>
<div v-if="opened"
:id="`header-menu-${id}`"
class="header-menu__wrapper"
role="menu">
<div class="header-menu__carret" />
<div class="header-menu__content">
<slot />
</div>
</div>
</div>
</template>
<script>
import { directive as ClickOutside } from 'v-click-outside'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
export default {
name: 'HeaderMenu',
directives: {
ClickOutside,
},
props: {
id: {
type: String,
required: true,
},
open: {
type: Boolean,
default: false,
},
},
data() {
return {
opened: this.open,
}
},
watch: {
open(newVal) {
this.opened = newVal
this.$nextTick(() => {
if (this.opened) {
this.openMenu()
} else {
this.closeMenu()
}
})
},
},
mounted() {
document.addEventListener('keydown', this.onKeyDown)
},
beforeMount() {
subscribe(`header-menu-${this.id}-close`, this.closeMenu)
subscribe(`header-menu-${this.id}-open`, this.openMenu)
},
beforeDestroy() {
unsubscribe(`header-menu-${this.id}-close`, this.closeMenu)
unsubscribe(`header-menu-${this.id}-open`, this.openMenu)
},
methods: {
/**
* Toggle the current menu open state
*/
toggleMenu() {
// Toggling current state
if (!this.opened) {
this.openMenu()
} else {
this.closeMenu()
}
},
/**
* Close the current menu
*/
closeMenu() {
if (!this.opened) {
return
}
this.opened = false
this.$emit('close')
this.$emit('update:open', false)
emit(`header-menu-${this.id}-close`)
},
/**
* Open the current menu
*/
openMenu() {
if (this.opened) {
return
}
this.opened = true
this.$emit('open')
this.$emit('update:open', true)
emit(`header-menu-${this.id}-open`)
},
onKeyDown(event) {
// If opened and escape pressed, close
if (event.key === 'Escape' && this.opened) {
event.preventDefault()
this.closeMenu()
}
},
},
}
</script>
<style lang="scss" scoped>
.header-menu {
&__trigger {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 100%;
margin: 0;
padding: 0;
cursor: pointer;
opacity: .6;
}
&--opened &__trigger,
&__trigger:hover,
&__trigger:focus,
&__trigger:active {
opacity: 1;
}
&__wrapper {
position: absolute;
z-index: 2000;
top: 50px;
right: 5px;
box-sizing: border-box;
margin: 0;
border-radius: 0 0 var(--border-radius) var(--border-radius);
background-color: var(--color-main-background);
filter: drop-shadow(0 1px 5px var(--color-box-shadow));
}
&__carret {
position: absolute;
right: 10px;
bottom: 100%;
width: 0;
height: 0;
content: ' ';
pointer-events: none;
border: 10px solid transparent;
border-bottom-color: var(--color-main-background);
}
&__content {
overflow: auto;
width: 350px;
max-width: 350px;
min-height: calc(44px * 1.5);
max-height: calc(100vh - 50px * 2);
}
}
</style>

View File

@ -0,0 +1,211 @@
<template>
<a :href="resourceUrl || '#'"
class="unified-search__result"
:class="{
'unified-search__result--focused': focused
}"
@click="reEmitEvent"
@focus="reEmitEvent">
<!-- Icon describing the result -->
<div class="unified-search__result-icon"
:class="{
'unified-search__result-icon--rounded': rounded,
'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
[iconClass]: true
}"
role="img">
<img v-if="hasValidThumbnail"
:src="thumbnailUrl"
:alt="t('core', 'Thumbnail for {result}', {result: title})"
@error="onError"
@load="onLoad">
</div>
<!-- Title and sub-title -->
<span class="unified-search__result-content">
<h3 class="unified-search__result-line-one">
<Highlight :text="title" :search="query" />
</h3>
<h4 v-if="subline" class="unified-search__result-line-two">{{ subline }}</h4>
</span>
</a>
</template>
<script>
import Highlight from '@nextcloud/vue/dist/Components/Highlight'
export default {
name: 'SearchResult',
components: {
Highlight,
},
props: {
thumbnailUrl: {
type: String,
default: null,
},
title: {
type: String,
required: true,
},
subline: {
type: String,
default: null,
},
resourceUrl: {
type: String,
default: null,
},
iconClass: {
type: String,
default: '',
},
rounded: {
type: Boolean,
default: false,
},
query: {
type: String,
default: '',
},
/**
* Only used for the first result as a visual feedback
* so we can keep the search input focused but pressing
* enter still opens the first result
*/
focused: {
type: Boolean,
default: false,
},
},
data() {
return {
hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
loaded: false,
}
},
watch: {
// Make sure to reset state on change even when vue recycle the component
thumbnailUrl() {
this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
this.loaded = false
},
},
methods: {
reEmitEvent(e) {
this.$emit(e.type, e)
},
/**
* If the image fails to load, fallback to iconClass
*/
onError() {
this.hasValidThumbnail = false
},
onLoad() {
this.loaded = true
},
},
}
</script>
<style lang="scss" scoped>
$clickable-area: 44px;
$margin: 10px;
.unified-search__result {
display: flex;
height: $clickable-area;
padding: $margin;
border-bottom: 1px solid var(--color-border);
// Load more entry,
&:last-child {
border-bottom: none;
}
&--focused,
&:active,
&:hover,
&:focus {
background-color: var(--color-background-hover);
}
* {
cursor: pointer;
}
&-icon {
overflow: hidden;
width: $clickable-area;
height: $clickable-area;
border-radius: var(--border-radius);
background-position: center center;
background-size: 32px;
&--rounded {
border-radius: $clickable-area / 2;
}
&--no-preview {
background-size: 32px;
}
&--with-thumbnail {
background-size: cover;
}
&--with-thumbnail:not(&--rounded) {
// compensate for border
max-width: $clickable-area - 2px;
max-height: $clickable-area - 2px;
border: 1px solid var(--color-border);
}
img {
// Make sure to keep ratio
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
}
&-icon,
&-actions {
flex: 0 0 $clickable-area;
}
&-content {
display: flex;
align-items: center;
flex: 1 1 100%;
flex-wrap: wrap;
// Set to minimum and gro from it
min-width: 0;
padding-left: $margin;
}
&-line-one,
&-line-two {
overflow: hidden;
flex: 1 1 100%;
margin: 0;
white-space: nowrap;
text-overflow: ellipsis;
// Use the same color as the `a`
color: inherit;
font-size: inherit;
}
&-line-two {
opacity: .7;
font-size: 14px;
}
}
</style>

View File

@ -41,7 +41,7 @@
import {
startAuthentication,
finishAuthentication,
} from '../../service/WebAuthnAuthenticationService'
} from '../../services/WebAuthnAuthenticationService'
import LoginButton from './LoginButton'
class NoValidCredentials extends Error {

View File

@ -0,0 +1,52 @@
/**
* @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
export const defaultLimit = loadState('unified-search', 'limit-default')
/**
* Get the list of available search providers
*/
export async function getTypes() {
try {
const { data } = await axios.get(generateUrl('/search/providers'))
if (Array.isArray(data) && data.length > 0) {
return data
}
} catch (error) {
console.error(error)
}
return []
}
/**
* Get the list of available search providers
*
* @param {string} type the type to search
* @param {string} query the search
* @returns {Promise}
*/
export function search(type, query) {
return axios.get(generateUrl(`/search/providers/${type}/search?term=${query}`))
}

View File

@ -0,0 +1,47 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getRequestToken } from '@nextcloud/auth'
import { generateFilePath } from '@nextcloud/router'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import UnifiedSearch from './views/UnifiedSearch.vue'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())
// eslint-disable-next-line camelcase
__webpack_public_path__ = generateFilePath('core', '', 'js/')
Vue.mixin({
methods: {
t,
n,
},
})
export default new Vue({
el: '#unified-search',
// eslint-disable-next-line vue/match-component-file-name
name: 'UnifiedSearchRoot',
render: h => h(UnifiedSearch),
})

View File

@ -451,7 +451,6 @@ export default {
const entry = event.target
const results = this.getResultsList()
const index = [...results].findIndex(search => search === entry)
console.info(entry, index)
if (index > -1) {
// let's not use focusIndex as the entry is already focused
this.focused = index

View File

@ -102,15 +102,7 @@
</div>
<div class="header-right">
<form class="searchbox" action="#" method="post" role="search" novalidate>
<label for="searchbox" class="hidden-visually">
<?php p($l->t('Search'));?>
</label>
<input id="searchbox" type="search" name="query"
value="" required class="hidden icon-search-white icon-search-force-white"
autocomplete="off">
<button class="icon-close-white" type="reset"><span class="hidden-visually"><?php p($l->t('Reset search'));?></span></button>
</form>
<div id="unified-search"></div>
<div id="contactsmenu">
<div class="icon-contacts menutoggle" tabindex="0" role="button"
aria-haspopup="true" aria-controls="contactsmenu-menu" aria-expanded="false">

View File

@ -4,19 +4,20 @@ const webpack = require('webpack')
module.exports = [
{
entry: {
files_client: path.join(__dirname, 'src/files/client.js'),
files_fileinfo: path.join(__dirname, 'src/files/fileinfo.js'),
files_iedavclient: path.join(__dirname, 'src/files/iedavclient.js'),
install: path.join(__dirname, 'src/install.js'),
login: path.join(__dirname, 'src/login.js'),
main: path.join(__dirname, 'src/main.js'),
maintenance: path.join(__dirname, 'src/maintenance.js'),
recommendedapps: path.join(__dirname, 'src/recommendedapps.js'),
install: path.join(__dirname, 'src/install.js'),
files_client: path.join(__dirname, 'src/files/client.js'),
files_fileinfo: path.join(__dirname, 'src/files/fileinfo.js'),
files_iedavclient: path.join(__dirname, 'src/files/iedavclient.js')
'unified-search': path.join(__dirname, 'src/unified-search.js'),
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'js/dist'),
jsonpFunction: 'webpackJsonpCore'
jsonpFunction: 'webpackJsonpCore',
},
module: {
rules: [
@ -26,25 +27,25 @@ module.exports = [
options: {
type: 'commonjs',
exports: 'dav',
}
}
]
},
},
],
},
plugins: [
new webpack.ProvidePlugin({
'_': "underscore",
$: "jquery",
jQuery: "jquery"
})
]
'_': 'underscore',
$: 'jquery',
jQuery: 'jquery',
}),
],
},
{
entry: {
systemtags: path.resolve(__dirname, 'src/systemtags/merged-systemtags.js')
systemtags: path.resolve(__dirname, 'src/systemtags/merged-systemtags.js'),
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'js/dist')
}
}
path: path.resolve(__dirname, 'js/dist'),
},
},
]

View File

@ -106,9 +106,9 @@ class SearchComposer {
}
/**
* Get a list of all provider IDs for the consecutive calls to `search`
* Get a list of all provider IDs & Names for the consecutive calls to `search`
*
* @return string[]
* @return array
*/
public function getProviders(): array {
$this->loadLazyProviders();
@ -118,7 +118,10 @@ class SearchComposer {
*/
return array_values(
array_map(function (IProvider $provider) {
return $provider->getId();
return [
'id' => $provider->getId(),
'name' => $provider->getName()
];
}, $this->providers));
}

View File

@ -28,7 +28,7 @@ namespace OC\Search;
use OCP\Search\ISearchQuery;
class SearchQuery implements ISearchQuery {
public const LIMIT_DEFAULT = 20;
public const LIMIT_DEFAULT = 5;
/** @var string */
private $term;

View File

@ -44,34 +44,40 @@
namespace OC;
use OC\AppFramework\Http\Request;
use OC\Search\SearchQuery;
use OC\Template\JSCombiner;
use OC\Template\JSConfigHelper;
use OC\Template\SCSSCacher;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Defaults;
use OCP\IConfig;
use OCP\IInitialStateService;
use OCP\Support\Subscription\IRegistry;
use OCP\Util;
class TemplateLayout extends \OC_Template {
private static $versionHash = '';
/**
* @var \OCP\IConfig
*/
/** @var IConfig */
private $config;
/** @var IInitialStateService */
private $initialState;
/**
* @param string $renderAs
* @param string $appId application id
*/
public function __construct($renderAs, $appId = '') {
// yes - should be injected ....
$this->config = \OC::$server->getConfig();
/** @var IConfig */
$this->config = \OC::$server->get(IConfig::class);
if (\OCP\Util::isIE()) {
\OC_Util::addStyle('ie');
/** @var IInitialStateService */
$this->initialState = \OC::$server->get(InitialStateService::class);
if (Util::isIE()) {
Util::addStyle('ie');
}
// Decide which page we show
@ -83,6 +89,9 @@ class TemplateLayout extends \OC_Template {
$this->assign('bodyid', 'body-user');
}
$this->initialState->provideInitialState('unified-search', 'limit-default', SearchQuery::LIMIT_DEFAULT);
Util::addScript('dist/unified-search', null, true);
// Add navigation entry
$this->assign('application', '');
$this->assign('appid', $appId);
@ -241,9 +250,7 @@ class TemplateLayout extends \OC_Template {
}
}
/** @var InitialStateService $initialState */
$initialState = \OC::$server->query(InitialStateService::class);
$this->assign('initialStates', $initialState->getInitialStates());
$this->assign('initialStates', $this->initialState->getInitialStates());
}
/**

View File

@ -68,22 +68,40 @@ abstract class ASearchResultEntry implements JsonSerializable {
*/
protected $resourceUrl;
/**
* @var string
* @since 20.0.0
*/
protected $iconClass;
/**
* @var boolean
* @since 20.0.0
*/
protected $rounded;
/**
* @param string $thumbnailUrl a relative or absolute URL to the thumbnail or icon of the entry
* @param string $title a main title of the entry
* @param string $subline the secondary line of the entry
* @param string $resourceUrl the URL where the user can find the detail, like a deep link inside the app
* @param string $iconClass the icon class fallback
* @param boolean $rounded is the thumbnail rounded
*
* @since 20.0.0
*/
public function __construct(string $thumbnailUrl,
string $title,
string $subline,
string $resourceUrl) {
string $resourceUrl,
string $iconClass = '',
bool $rounded = false) {
$this->thumbnailUrl = $thumbnailUrl;
$this->title = $title;
$this->subline = $subline;
$this->resourceUrl = $resourceUrl;
$this->iconClass = $iconClass;
$this->rounded = $rounded;
}
/**
@ -97,6 +115,8 @@ abstract class ASearchResultEntry implements JsonSerializable {
'title' => $this->title,
'subline' => $this->subline,
'resourceUrl' => $this->resourceUrl,
'iconClass' => $this->iconClass,
'rounded' => $this->rounded,
];
}
}

View File

@ -53,6 +53,17 @@ interface IProvider {
*/
public function getId(): string;
/**
* Get the translated name of this search provider
*
* Example: 'Mail', 'Contacts'...
*
* @return string
*
* @since 20.0.0
*/
public function getName(): string;
/**
* Find matching search entries in an app
*

View File

@ -72,6 +72,7 @@
"strengthify": "git+https://github.com/MorrisJobke/strengthify.git#0.5.8",
"underscore": "^1.10.2",
"url-search-params-polyfill": "^8.0.0",
"v-click-outside": "^3.0.1",
"v-tooltip": "^2.0.3",
"vue": "^2.6.11",
"vue-click-outside": "^1.1.0",