mirror of
https://github.com/nextcloud/server.git
synced 2026-06-08 08:16:43 -04:00
Merge pull request #22088 from nextcloud/enh/unified-search-fix
Fix unified search
This commit is contained in:
commit
db5ac969f9
39 changed files with 675 additions and 80 deletions
|
|
@ -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',
|
||||
[
|
||||
'dir' => $pathInfo['dirname'],
|
||||
'scrollto' => $pathInfo['basename'],
|
||||
]
|
||||
)
|
||||
$this->urlGenerator->linkToRoute('files.view.index',[
|
||||
'dir' => $pathInfo['dirname'],
|
||||
'scrollto' => $pathInfo['basename'],
|
||||
]),
|
||||
'',
|
||||
true
|
||||
);
|
||||
}, $this->legacyProvider->search($query->getTerm()))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,6 @@
|
|||
|
||||
--animation-quick: $animation-quick;
|
||||
--animation-slow: $animation-slow;
|
||||
|
||||
--header-height: $header-height;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
2
core/js/dist/files_client.js
vendored
2
core/js/dist/files_client.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/files_client.js.map
vendored
2
core/js/dist/files_client.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/files_fileinfo.js
vendored
2
core/js/dist/files_fileinfo.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/files_fileinfo.js.map
vendored
2
core/js/dist/files_fileinfo.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/files_iedavclient.js
vendored
2
core/js/dist/files_iedavclient.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/files_iedavclient.js.map
vendored
2
core/js/dist/files_iedavclient.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/install.js
vendored
2
core/js/dist/install.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/install.js.map
vendored
2
core/js/dist/install.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/login.js
vendored
2
core/js/dist/login.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/login.js.map
vendored
2
core/js/dist/login.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/main.js
vendored
2
core/js/dist/main.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/main.js.map
vendored
2
core/js/dist/main.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/maintenance.js
vendored
2
core/js/dist/maintenance.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/maintenance.js.map
vendored
2
core/js/dist/maintenance.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/recommendedapps.js
vendored
2
core/js/dist/recommendedapps.js
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/recommendedapps.js.map
vendored
2
core/js/dist/recommendedapps.js.map
vendored
File diff suppressed because one or more lines are too long
2
core/js/dist/unified-search.js
vendored
Normal file
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
1
core/js/dist/unified-search.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
206
core/src/components/HeaderMenu.vue
Normal file
206
core/src/components/HeaderMenu.vue
Normal 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>
|
||||
211
core/src/components/UnifiedSearch/SearchResult.vue
Normal file
211
core/src/components/UnifiedSearch/SearchResult.vue
Normal 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>
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
import {
|
||||
startAuthentication,
|
||||
finishAuthentication,
|
||||
} from '../../service/WebAuthnAuthenticationService'
|
||||
} from '../../services/WebAuthnAuthenticationService'
|
||||
import LoginButton from './LoginButton'
|
||||
|
||||
class NoValidCredentials extends Error {
|
||||
|
|
|
|||
52
core/src/services/UnifiedSearchService.js
Normal file
52
core/src/services/UnifiedSearchService.js
Normal 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}`))
|
||||
}
|
||||
47
core/src/unified-search.js
Normal file
47
core/src/unified-search.js
Normal 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),
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -240,10 +249,8 @@ 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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue