]> source.dussan.org Git - nextcloud-server.git/commitdiff
Rename "global search" to "unified search"
authorfenn-cs <fenn25.fn@gmail.com>
Thu, 7 Dec 2023 12:20:59 +0000 (13:20 +0100)
committerFerdinand Thiessen <opensource@fthiessen.de>
Tue, 12 Dec 2023 19:34:37 +0000 (20:34 +0100)
- Changes appearances of "Global search" to "Unified search" in UI
- Refactors code, to remove usage of term "GlobalSearch" in files and code
 structure
- Rename old unified search to `legacy-unified-search`

Signed-off-by: fenn-cs <fenn25.fn@gmail.com>
23 files changed:
core/src/components/GlobalSearch/CustomDateRangeModal.vue [deleted file]
core/src/components/GlobalSearch/SearchFilterChip.vue [deleted file]
core/src/components/GlobalSearch/SearchResult.vue [deleted file]
core/src/components/GlobalSearch/SearchableList.vue [deleted file]
core/src/components/UnifiedSearch/CustomDateRangeModal.vue [new file with mode: 0644]
core/src/components/UnifiedSearch/LegacySearchResult.vue [new file with mode: 0644]
core/src/components/UnifiedSearch/SearchFilterChip.vue [new file with mode: 0644]
core/src/components/UnifiedSearch/SearchResult.vue
core/src/components/UnifiedSearch/SearchableList.vue [new file with mode: 0644]
core/src/global-search.js [deleted file]
core/src/legacy-unified-search.js [new file with mode: 0644]
core/src/services/GlobalSearchService.js [deleted file]
core/src/services/LegacyUnifiedSearchService.js [new file with mode: 0644]
core/src/services/UnifiedSearchService.js
core/src/unified-search.js
core/src/views/GlobalSearch.vue [deleted file]
core/src/views/GlobalSearchModal.vue [deleted file]
core/src/views/LegacyUnifiedSearch.vue [new file with mode: 0644]
core/src/views/UnifiedSearch.vue
core/src/views/UnifiedSearchModal.vue [new file with mode: 0644]
core/templates/layout.user.php
lib/private/TemplateLayout.php
webpack.modules.js

diff --git a/core/src/components/GlobalSearch/CustomDateRangeModal.vue b/core/src/components/GlobalSearch/CustomDateRangeModal.vue
deleted file mode 100644 (file)
index 0ba6ddc..0000000
+++ /dev/null
@@ -1,103 +0,0 @@
-<template>
-       <NcModal v-if="isModalOpen"
-               id="global-search"
-               :name="t('core', 'Custom date range')"
-               :show.sync="isModalOpen"
-               :size="'small'"
-               :clear-view-delay="0"
-               :title="t('core', 'Custom date range')"
-               @close="closeModal">
-               <!-- Custom date range -->
-               <div class="global-search-custom-date-modal">
-                       <h1>{{ t('core', 'Custom date range') }}</h1>
-                       <div class="global-search-custom-date-modal__pickers">
-                               <NcDateTimePicker :id="'globalsearch-custom-date-range-start'"
-                                       v-model="dateFilter.startFrom"
-                                       :label="t('core', 'Pick start date')"
-                                       type="date" />
-                               <NcDateTimePicker :id="'globalsearch-custom-date-range-end'"
-                                       v-model="dateFilter.endAt"
-                                       :label="t('core', 'Pick end date')"
-                                       type="date" />
-                       </div>
-                       <div class="global-search-custom-date-modal__footer">
-                               <NcButton @click="applyCustomRange">
-                                       {{ t('core', 'Search in date range') }}
-                                       <template #icon>
-                                               <CalendarRangeIcon :size="20" />
-                                       </template>
-                               </NcButton>
-                       </div>
-               </div>
-       </NcModal>
-</template>
-
-<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
-
-export default {
-       name: 'CustomDateRangeModal',
-       components: {
-               NcButton,
-               NcModal,
-               CalendarRangeIcon,
-               NcDateTimePicker,
-       },
-       props: {
-               isOpen: {
-                       type: Boolean,
-                       required: true,
-               },
-       },
-       data() {
-               return {
-                       dateFilter: { startFrom: null, endAt: null },
-               }
-       },
-       computed: {
-               isModalOpen: {
-                       get() {
-                               return this.isOpen
-                       },
-                       set(value) {
-                               this.$emit('update:is-open', value)
-                       },
-               },
-       },
-       methods: {
-               closeModal() {
-                       this.isModalOpen = false
-               },
-               applyCustomRange() {
-                       this.$emit('set:custom-date-range', this.dateFilter)
-                       this.closeModal()
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-.global-search-custom-date-modal {
-       padding: 10px 20px 10px 20px;
-
-       h1 {
-               font-size: 16px;
-               font-weight: bolder;
-               line-height: 2em;
-       }
-
-       &__pickers {
-               display: flex;
-               flex-direction: column;
-       }
-
-       &__footer {
-               display: flex;
-               justify-content: end;
-       }
-
-}
-</style>
diff --git a/core/src/components/GlobalSearch/SearchFilterChip.vue b/core/src/components/GlobalSearch/SearchFilterChip.vue
deleted file mode 100644 (file)
index 8342e9e..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-<template>
-       <div class="chip">
-               <span class="icon">
-                       <slot name="icon" />
-                       <span v-if="pretext.length"> {{ pretext }} : </span>
-               </span>
-               <span class="text">{{ text }}</span>
-               <span class="close-icon" @click="deleteChip">
-                       <CloseIcon :size="18" />
-               </span>
-       </div>
-</template>
-
-<script>
-import CloseIcon from 'vue-material-design-icons/Close.vue'
-
-export default {
-       name: 'SearchFilterChip',
-       components: {
-               CloseIcon,
-       },
-       props: {
-               text: {
-                       type: String,
-                       required: true,
-               },
-               pretext: {
-                       type: String,
-                       required: true,
-               },
-       },
-       methods: {
-               deleteChip() {
-                       this.$emit('delete', this.filter)
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-.chip {
-    display: flex;
-    align-items: center;
-    padding: 2px 4px;
-    border: 1px solid var(--color-primary-element-light);
-    border-radius: 20px;
-    background-color: var(--color-primary-element-light);
-    margin: 2px;
-
-    .icon {
-        display: flex;
-        align-items: center;
-        padding-right: 5px;
-
-        img {
-            width: 20px;
-            padding: 2px;
-            border-radius: 20px;
-            filter: var(--background-invert-if-bright);
-        }
-    }
-
-    .text {
-        margin: 0 2px;
-    }
-
-    .close-icon {
-        cursor: pointer ;
-
-        :hover {
-            filter: invert(20%);
-        }
-    }
-}
-</style>
diff --git a/core/src/components/GlobalSearch/SearchResult.vue b/core/src/components/GlobalSearch/SearchResult.vue
deleted file mode 100644 (file)
index a746a57..0000000
+++ /dev/null
@@ -1,169 +0,0 @@
-<template>
-       <NcListItem class="result-items__item"
-               :name="title"
-               :bold="false"
-               :href="resourceUrl"
-               target="_self">
-               <template #icon>
-                       <div aria-hidden="true"
-                               class="result-items__item-icon"
-                               :class="{
-                                       'result-items__item-icon--rounded': rounded,
-                                       'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
-                                       'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
-                                       [icon]: !isValidIconOrPreviewUrl(icon),
-                               }"
-                               :style="{
-                                       backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
-                               }">
-                               <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
-                                       :src="thumbnailUrl"
-                                       @error="thumbnailErrorHandler">
-                       </div>
-               </template>
-               <template #subname>
-                       {{ subline }}
-               </template>
-       </NcListItem>
-</template>
-
-<script>
-import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
-
-export default {
-       name: 'SearchResult',
-       components: {
-               NcListItem,
-       },
-       props: {
-               thumbnailUrl: {
-                       type: String,
-                       default: null,
-               },
-               title: {
-                       type: String,
-                       required: true,
-               },
-               subline: {
-                       type: String,
-                       default: null,
-               },
-               resourceUrl: {
-                       type: String,
-                       default: null,
-               },
-               icon: {
-                       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 {
-                       thumbnailHasError: false,
-               }
-       },
-       watch: {
-               thumbnailUrl() {
-                       this.thumbnailHasError = false
-               },
-       },
-       methods: {
-               isValidIconOrPreviewUrl(url) {
-                       return /^https?:\/\//.test(url) || url.startsWith('/')
-               },
-               thumbnailErrorHandler() {
-                       this.thumbnailHasError = true
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-@use "sass:math";
-$clickable-area: 44px;
-$margin: 10px;
-
-.result-items {
-    &__item {
-
-    ::v-deep a {
-            border-radius: 12px;
-            border: 2px solid transparent;
-            border-radius: var(--border-radius-large) !important;
-
-            &--focused {
-                background-color: var(--color-background-hover);
-            }
-
-            &:active,
-            &:hover,
-            &:focus {
-                background-color: var(--color-background-hover);
-                border: 2px solid var(--color-border-maxcontrast);
-            }
-
-            * {
-                cursor: pointer;
-            }
-
-        }
-
-        &-icon {
-            overflow: hidden;
-            width: $clickable-area;
-            height: $clickable-area;
-            border-radius: var(--border-radius);
-            background-repeat: no-repeat;
-            background-position: center center;
-            background-size: 32px;
-
-            &--rounded {
-                border-radius: math.div($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;
-            }
-        }
-
-    }
-}
-</style>
diff --git a/core/src/components/GlobalSearch/SearchableList.vue b/core/src/components/GlobalSearch/SearchableList.vue
deleted file mode 100644 (file)
index 43f7ace..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-<!--
-  - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me>
-  -
-  - @author Marco Ambrosini <marcoambrosini@proton.me>
-  -
-  - @license AGPL-3.0-or-later
-  -
-  - 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>
-       <NcPopover :shown="opened"
-               @show="opened = true"
-               @hide="opened = false">
-               <template #trigger>
-                       <slot ref="popoverTrigger" name="trigger" />
-               </template>
-               <div class="searchable-list__wrapper">
-                       <NcTextField :value.sync="searchTerm"
-                               :label="labelText"
-                               trailing-button-icon="close"
-                               :show-trailing-button="searchTerm !== ''"
-                               @trailing-button-click="clearSearch">
-                               <Magnify :size="20" />
-                       </NcTextField>
-                       <ul v-if="filteredList.length > 0" class="searchable-list__list">
-                               <li v-for="element in filteredList"
-                                       :key="element.id"
-                                       :title="element.displayName"
-                                       role="button">
-                                       <NcButton alignment="start"
-                                               type="tertiary"
-                                               :wide="true"
-                                               @click="itemSelected(element)">
-                                               <template #icon>
-                                                       <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" />
-                                               </template>
-                                               {{ element.displayName }}
-                                       </NcButton>
-                               </li>
-                       </ul>
-                       <div v-else class="searchable-list__empty-content">
-                               <NcEmptyContent :name="emptyContentText">
-                                       <template #icon>
-                                               <AlertCircleOutline />
-                                       </template>
-                               </NcEmptyContent>
-                       </div>
-               </div>
-       </NcPopover>
-</template>
-
-<script>
-import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue'
-
-import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
-
-export default {
-       name: 'SearchableList',
-
-       components: {
-               NcPopover,
-               NcTextField,
-               Magnify,
-               AlertCircleOutline,
-               NcAvatar,
-               NcEmptyContent,
-               NcButton,
-       },
-
-       props: {
-               labelText: {
-                       type: String,
-                       default: 'this is a label',
-               },
-
-               searchList: {
-                       type: Array,
-                       required: true,
-               },
-
-               emptyContentText: {
-                       type: String,
-                       required: true,
-               },
-       },
-
-       data() {
-               return {
-                       opened: false,
-                       error: false,
-                       searchTerm: '',
-               }
-       },
-
-       computed: {
-               filteredList() {
-                       return this.searchList.filter((element) => {
-                               if (!this.searchTerm.toLowerCase().length) {
-                                       return true
-                               }
-                               return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
-                       })
-               },
-       },
-
-       methods: {
-               clearSearch() {
-                       this.searchTerm = ''
-               },
-               itemSelected(element) {
-                       this.$emit('item-selected', element)
-                       this.clearSearch()
-                       this.opened = false
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-.searchable-list {
-       &__wrapper {
-               padding: calc(var(--default-grid-baseline) * 3);
-               display: flex;
-               flex-direction: column;
-               align-items: center;
-               width: 250px;
-       }
-
-       &__list {
-               width: 100%;
-               max-height: 284px;
-               overflow-y: auto;
-               margin-top: var(--default-grid-baseline);
-               padding: var(--default-grid-baseline);
-
-               :deep(.button-vue) {
-                       border-radius: var(--border-radius-large) !important;
-                       span {
-                               font-weight: initial;
-                       }
-               }
-       }
-
-       &__empty-content {
-               margin-top: calc(var(--default-grid-baseline) * 3);
-       }
-}
-</style>
diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
new file mode 100644 (file)
index 0000000..ec59273
--- /dev/null
@@ -0,0 +1,103 @@
+<template>
+       <NcModal v-if="isModalOpen"
+               id="unified-search"
+               :name="t('core', 'Custom date range')"
+               :show.sync="isModalOpen"
+               :size="'small'"
+               :clear-view-delay="0"
+               :title="t('core', 'Custom date range')"
+               @close="closeModal">
+               <!-- Custom date range -->
+               <div class="unified-search-custom-date-modal">
+                       <h1>{{ t('core', 'Custom date range') }}</h1>
+                       <div class="unified-search-custom-date-modal__pickers">
+                               <NcDateTimePicker :id="'unifiedsearch-custom-date-range-start'"
+                                       v-model="dateFilter.startFrom"
+                                       :label="t('core', 'Pick start date')"
+                                       type="date" />
+                               <NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'"
+                                       v-model="dateFilter.endAt"
+                                       :label="t('core', 'Pick end date')"
+                                       type="date" />
+                       </div>
+                       <div class="unified-search-custom-date-modal__footer">
+                               <NcButton @click="applyCustomRange">
+                                       {{ t('core', 'Search in date range') }}
+                                       <template #icon>
+                                               <CalendarRangeIcon :size="20" />
+                                       </template>
+                               </NcButton>
+                       </div>
+               </div>
+       </NcModal>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcDateTimePicker from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
+import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
+
+export default {
+       name: 'CustomDateRangeModal',
+       components: {
+               NcButton,
+               NcModal,
+               CalendarRangeIcon,
+               NcDateTimePicker,
+       },
+       props: {
+               isOpen: {
+                       type: Boolean,
+                       required: true,
+               },
+       },
+       data() {
+               return {
+                       dateFilter: { startFrom: null, endAt: null },
+               }
+       },
+       computed: {
+               isModalOpen: {
+                       get() {
+                               return this.isOpen
+                       },
+                       set(value) {
+                               this.$emit('update:is-open', value)
+                       },
+               },
+       },
+       methods: {
+               closeModal() {
+                       this.isModalOpen = false
+               },
+               applyCustomRange() {
+                       this.$emit('set:custom-date-range', this.dateFilter)
+                       this.closeModal()
+               },
+       },
+}
+</script>
+
+<style lang="scss" scoped>
+.unified-search-custom-date-modal {
+       padding: 10px 20px 10px 20px;
+
+       h1 {
+               font-size: 16px;
+               font-weight: bolder;
+               line-height: 2em;
+       }
+
+       &__pickers {
+               display: flex;
+               flex-direction: column;
+       }
+
+       &__footer {
+               display: flex;
+               justify-content: end;
+       }
+
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue
new file mode 100644 (file)
index 0000000..01f48a3
--- /dev/null
@@ -0,0 +1,259 @@
+ <!--
+  - @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>
+       <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,
+                               [icon]: !loaded && !isIconUrl,
+                       }"
+                       :style="{
+                               backgroundImage: isIconUrl ? `url(${icon})` : '',
+                       }">
+
+                       <img v-if="hasValidThumbnail"
+                               v-show="loaded"
+                               :src="thumbnailUrl"
+                               alt=""
+                               @error="onError"
+                               @load="onLoad">
+               </div>
+
+               <!-- Title and sub-title -->
+               <span class="unified-search__result-content">
+                       <span class="unified-search__result-line-one" :title="title">
+                               <NcHighlight :text="title" :search="query" />
+                       </span>
+                       <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
+               </span>
+       </a>
+</template>
+
+<script>
+import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
+
+export default {
+       name: 'LegacySearchResult',
+
+       components: {
+               NcHighlight,
+       },
+
+       props: {
+               thumbnailUrl: {
+                       type: String,
+                       default: null,
+               },
+               title: {
+                       type: String,
+                       required: true,
+               },
+               subline: {
+                       type: String,
+                       default: null,
+               },
+               resourceUrl: {
+                       type: String,
+                       default: null,
+               },
+               icon: {
+                       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,
+               }
+       },
+
+       computed: {
+               isIconUrl() {
+                       // If we're facing an absolute url
+                       if (this.icon.startsWith('/')) {
+                               return true
+                       }
+
+                       // Otherwise, let's check if this is a valid url
+                       try {
+                               // eslint-disable-next-line no-new
+                               new URL(this.icon)
+                       } catch {
+                               return false
+                       }
+                       return true
+               },
+       },
+
+       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>
+@use "sass:math";
+
+$clickable-area: 44px;
+$margin: 10px;
+
+.unified-search__result {
+       display: flex;
+       align-items: center;
+       height: $clickable-area;
+       padding: $margin;
+       border: 2px solid transparent;
+       border-radius: var(--border-radius-large) !important;
+
+       &--focused {
+               background-color: var(--color-background-hover);
+       }
+
+       &:active,
+       &:hover,
+       &:focus {
+               background-color: var(--color-background-hover);
+               border: 2px solid var(--color-border-maxcontrast);
+       }
+
+       * {
+               cursor: pointer;
+       }
+
+       &-icon {
+               overflow: hidden;
+               width: $clickable-area;
+               height: $clickable-area;
+               border-radius: var(--border-radius);
+               background-repeat: no-repeat;
+               background-position: center center;
+               background-size: 32px;
+               &--rounded {
+                       border-radius: math.div($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: 1px 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: var(--default-font-size);
+       }
+}
+
+</style>
diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue
new file mode 100644 (file)
index 0000000..8342e9e
--- /dev/null
@@ -0,0 +1,75 @@
+<template>
+       <div class="chip">
+               <span class="icon">
+                       <slot name="icon" />
+                       <span v-if="pretext.length"> {{ pretext }} : </span>
+               </span>
+               <span class="text">{{ text }}</span>
+               <span class="close-icon" @click="deleteChip">
+                       <CloseIcon :size="18" />
+               </span>
+       </div>
+</template>
+
+<script>
+import CloseIcon from 'vue-material-design-icons/Close.vue'
+
+export default {
+       name: 'SearchFilterChip',
+       components: {
+               CloseIcon,
+       },
+       props: {
+               text: {
+                       type: String,
+                       required: true,
+               },
+               pretext: {
+                       type: String,
+                       required: true,
+               },
+       },
+       methods: {
+               deleteChip() {
+                       this.$emit('delete', this.filter)
+               },
+       },
+}
+</script>
+
+<style lang="scss" scoped>
+.chip {
+    display: flex;
+    align-items: center;
+    padding: 2px 4px;
+    border: 1px solid var(--color-primary-element-light);
+    border-radius: 20px;
+    background-color: var(--color-primary-element-light);
+    margin: 2px;
+
+    .icon {
+        display: flex;
+        align-items: center;
+        padding-right: 5px;
+
+        img {
+            width: 20px;
+            padding: 2px;
+            border-radius: 20px;
+            filter: var(--background-invert-if-bright);
+        }
+    }
+
+    .text {
+        margin: 0 2px;
+    }
+
+    .close-icon {
+        cursor: pointer ;
+
+        :hover {
+            filter: invert(20%);
+        }
+    }
+}
+</style>
index 03496fc5d924c14e53858ea30b87047451129cec..a746a5751b70645c3c87150da6d608459d86b46f 100644 (file)
@@ -1,73 +1,40 @@
- <!--
-  - @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>
-       <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,
-                               [icon]: !loaded && !isIconUrl,
-                       }"
-                       :style="{
-                               backgroundImage: isIconUrl ? `url(${icon})` : '',
-                       }">
-
-                       <img v-if="hasValidThumbnail"
-                               v-show="loaded"
-                               :src="thumbnailUrl"
-                               alt=""
-                               @error="onError"
-                               @load="onLoad">
-               </div>
-
-               <!-- Title and sub-title -->
-               <span class="unified-search__result-content">
-                       <span class="unified-search__result-line-one" :title="title">
-                               <NcHighlight :text="title" :search="query" />
-                       </span>
-                       <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
-               </span>
-       </a>
+       <NcListItem class="result-items__item"
+               :name="title"
+               :bold="false"
+               :href="resourceUrl"
+               target="_self">
+               <template #icon>
+                       <div aria-hidden="true"
+                               class="result-items__item-icon"
+                               :class="{
+                                       'result-items__item-icon--rounded': rounded,
+                                       'result-items__item-icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
+                                       'result-items__item-icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
+                                       [icon]: !isValidIconOrPreviewUrl(icon),
+                               }"
+                               :style="{
+                                       backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
+                               }">
+                               <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
+                                       :src="thumbnailUrl"
+                                       @error="thumbnailErrorHandler">
+                       </div>
+               </template>
+               <template #subname>
+                       {{ subline }}
+               </template>
+       </NcListItem>
 </template>
 
 <script>
-import NcHighlight from '@nextcloud/vue/dist/Components/NcHighlight.js'
+import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
 
 export default {
        name: 'SearchResult',
-
        components: {
-               NcHighlight,
+               NcListItem,
        },
-
        props: {
                thumbnailUrl: {
                        type: String,
@@ -108,54 +75,22 @@ export default {
                        default: false,
                },
        },
-
        data() {
                return {
-                       hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
-                       loaded: false,
+                       thumbnailHasError: false,
                }
        },
-
-       computed: {
-               isIconUrl() {
-                       // If we're facing an absolute url
-                       if (this.icon.startsWith('/')) {
-                               return true
-                       }
-
-                       // Otherwise, let's check if this is a valid url
-                       try {
-                               // eslint-disable-next-line no-new
-                               new URL(this.icon)
-                       } catch {
-                               return false
-                       }
-                       return true
-               },
-       },
-
        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
+                       this.thumbnailHasError = false
                },
        },
-
        methods: {
-               reEmitEvent(e) {
-                       this.$emit(e.type, e)
-               },
-
-               /**
-                * If the image fails to load, fallback to iconClass
-                */
-               onError() {
-                       this.hasValidThumbnail = false
+               isValidIconOrPreviewUrl(url) {
+                       return /^https?:\/\//.test(url) || url.startsWith('/')
                },
-
-               onLoad() {
-                       this.loaded = true
+               thumbnailErrorHandler() {
+                       this.thumbnailHasError = true
                },
        },
 }
@@ -163,97 +98,72 @@ export default {
 
 <style lang="scss" scoped>
 @use "sass:math";
-
 $clickable-area: 44px;
 $margin: 10px;
 
-.unified-search__result {
-       display: flex;
-       align-items: center;
-       height: $clickable-area;
-       padding: $margin;
-       border: 2px solid transparent;
-       border-radius: var(--border-radius-large) !important;
-
-       &--focused {
-               background-color: var(--color-background-hover);
-       }
-
-       &:active,
-       &:hover,
-       &:focus {
-               background-color: var(--color-background-hover);
-               border: 2px solid var(--color-border-maxcontrast);
-       }
-
-       * {
-               cursor: pointer;
-       }
-
-       &-icon {
-               overflow: hidden;
-               width: $clickable-area;
-               height: $clickable-area;
-               border-radius: var(--border-radius);
-               background-repeat: no-repeat;
-               background-position: center center;
-               background-size: 32px;
-               &--rounded {
-                       border-radius: math.div($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: 1px 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: var(--default-font-size);
-       }
+.result-items {
+    &__item {
+
+    ::v-deep a {
+            border-radius: 12px;
+            border: 2px solid transparent;
+            border-radius: var(--border-radius-large) !important;
+
+            &--focused {
+                background-color: var(--color-background-hover);
+            }
+
+            &:active,
+            &:hover,
+            &:focus {
+                background-color: var(--color-background-hover);
+                border: 2px solid var(--color-border-maxcontrast);
+            }
+
+            * {
+                cursor: pointer;
+            }
+
+        }
+
+        &-icon {
+            overflow: hidden;
+            width: $clickable-area;
+            height: $clickable-area;
+            border-radius: var(--border-radius);
+            background-repeat: no-repeat;
+            background-position: center center;
+            background-size: 32px;
+
+            &--rounded {
+                border-radius: math.div($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;
+            }
+        }
+
+    }
 }
-
 </style>
diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue
new file mode 100644 (file)
index 0000000..43f7ace
--- /dev/null
@@ -0,0 +1,162 @@
+<!--
+  - @copyright 2023 Marco Ambrosini <marcoambrosini@proton.me>
+  -
+  - @author Marco Ambrosini <marcoambrosini@proton.me>
+  -
+  - @license AGPL-3.0-or-later
+  -
+  - 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>
+       <NcPopover :shown="opened"
+               @show="opened = true"
+               @hide="opened = false">
+               <template #trigger>
+                       <slot ref="popoverTrigger" name="trigger" />
+               </template>
+               <div class="searchable-list__wrapper">
+                       <NcTextField :value.sync="searchTerm"
+                               :label="labelText"
+                               trailing-button-icon="close"
+                               :show-trailing-button="searchTerm !== ''"
+                               @trailing-button-click="clearSearch">
+                               <Magnify :size="20" />
+                       </NcTextField>
+                       <ul v-if="filteredList.length > 0" class="searchable-list__list">
+                               <li v-for="element in filteredList"
+                                       :key="element.id"
+                                       :title="element.displayName"
+                                       role="button">
+                                       <NcButton alignment="start"
+                                               type="tertiary"
+                                               :wide="true"
+                                               @click="itemSelected(element)">
+                                               <template #icon>
+                                                       <NcAvatar :user="element.user" :show-user-status="false" :hide-favorite="false" />
+                                               </template>
+                                               {{ element.displayName }}
+                                       </NcButton>
+                               </li>
+                       </ul>
+                       <div v-else class="searchable-list__empty-content">
+                               <NcEmptyContent :name="emptyContentText">
+                                       <template #icon>
+                                               <AlertCircleOutline />
+                                       </template>
+                               </NcEmptyContent>
+                       </div>
+               </div>
+       </NcPopover>
+</template>
+
+<script>
+import { NcPopover, NcTextField, NcAvatar, NcEmptyContent, NcButton } from '@nextcloud/vue'
+
+import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+export default {
+       name: 'SearchableList',
+
+       components: {
+               NcPopover,
+               NcTextField,
+               Magnify,
+               AlertCircleOutline,
+               NcAvatar,
+               NcEmptyContent,
+               NcButton,
+       },
+
+       props: {
+               labelText: {
+                       type: String,
+                       default: 'this is a label',
+               },
+
+               searchList: {
+                       type: Array,
+                       required: true,
+               },
+
+               emptyContentText: {
+                       type: String,
+                       required: true,
+               },
+       },
+
+       data() {
+               return {
+                       opened: false,
+                       error: false,
+                       searchTerm: '',
+               }
+       },
+
+       computed: {
+               filteredList() {
+                       return this.searchList.filter((element) => {
+                               if (!this.searchTerm.toLowerCase().length) {
+                                       return true
+                               }
+                               return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
+                       })
+               },
+       },
+
+       methods: {
+               clearSearch() {
+                       this.searchTerm = ''
+               },
+               itemSelected(element) {
+                       this.$emit('item-selected', element)
+                       this.clearSearch()
+                       this.opened = false
+               },
+       },
+}
+</script>
+
+<style lang="scss" scoped>
+.searchable-list {
+       &__wrapper {
+               padding: calc(var(--default-grid-baseline) * 3);
+               display: flex;
+               flex-direction: column;
+               align-items: center;
+               width: 250px;
+       }
+
+       &__list {
+               width: 100%;
+               max-height: 284px;
+               overflow-y: auto;
+               margin-top: var(--default-grid-baseline);
+               padding: var(--default-grid-baseline);
+
+               :deep(.button-vue) {
+                       border-radius: var(--border-radius-large) !important;
+                       span {
+                               font-weight: initial;
+                       }
+               }
+       }
+
+       &__empty-content {
+               margin-top: calc(var(--default-grid-baseline) * 3);
+       }
+}
+</style>
diff --git a/core/src/global-search.js b/core/src/global-search.js
deleted file mode 100644 (file)
index f0c47fa..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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 { getLoggerBuilder } from '@nextcloud/logger'
-import { getRequestToken } from '@nextcloud/auth'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import Vue from 'vue'
-
-import GlobalSearch from './views/GlobalSearch.vue'
-
-// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
-
-const logger = getLoggerBuilder()
-       .setApp('global-search')
-       .detectUser()
-       .build()
-
-Vue.mixin({
-       data() {
-               return {
-                       logger,
-               }
-       },
-       methods: {
-               t,
-               n,
-       },
-})
-
-export default new Vue({
-       el: '#global-search',
-       // eslint-disable-next-line vue/match-component-file-name
-       name: 'GlobalSearchRoot',
-       render: h => h(GlobalSearch),
-})
diff --git a/core/src/legacy-unified-search.js b/core/src/legacy-unified-search.js
new file mode 100644 (file)
index 0000000..943081f
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * 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 { getLoggerBuilder } from '@nextcloud/logger'
+import { getRequestToken } from '@nextcloud/auth'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import UnifiedSearch from './views/LegacyUnifiedSearch.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = btoa(getRequestToken())
+
+const logger = getLoggerBuilder()
+       .setApp('unified-search')
+       .detectUser()
+       .build()
+
+Vue.mixin({
+       data() {
+               return {
+                       logger,
+               }
+       },
+       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),
+})
diff --git a/core/src/services/GlobalSearchService.js b/core/src/services/GlobalSearchService.js
deleted file mode 100644 (file)
index e477a59..0000000
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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 { generateOcsUrl, generateUrl } from '@nextcloud/router'
-import axios from '@nextcloud/axios'
-
-/**
- * Create a cancel token
- *
- * @return {import('axios').CancelTokenSource}
- */
-const createCancelToken = () => axios.CancelToken.source()
-
-/**
- * Get the list of available search providers
- *
- * @return {Promise<Array>}
- */
-export async function getProviders() {
-       try {
-               const { data } = await axios.get(generateOcsUrl('search/providers'), {
-                       params: {
-                               // Sending which location we're currently at
-                               from: window.location.pathname.replace('/index.php', '') + window.location.search,
-                       },
-               })
-               if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
-                       // Providers are sorted by the api based on their order key
-                       return data.ocs.data
-               }
-       } catch (error) {
-               console.error(error)
-       }
-       return []
-}
-
-/**
- * Get the list of available search providers
- *
- * @param {object} options destructuring object
- * @param {string} options.type the type to search
- * @param {string} options.query the search
- * @param {number|string|undefined} options.cursor the offset for paginated searches
- * @param {string} options.since the search
- * @param {string} options.until the search
- * @param {string} options.limit the search
- * @param {string} options.person the search
- * @return {object} {request: Promise, cancel: Promise}
- */
-export function search({ type, query, cursor, since, until, limit, person }) {
-       /**
-        * Generate an axios cancel token
-        */
-       const cancelToken = createCancelToken()
-
-       const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
-               cancelToken: cancelToken.token,
-               params: {
-                       term: query,
-                       cursor,
-                       since,
-                       until,
-                       limit,
-                       person,
-                       // Sending which location we're currently at
-                       from: window.location.pathname.replace('/index.php', '') + window.location.search,
-               },
-       })
-
-       return {
-               request,
-               cancel: cancelToken.cancel,
-       }
-}
-
-/**
- * Get the list of active contacts
- *
- * @param {object} filter filter contacts by string
- * @param filter.searchTerm
- * @return {object} {request: Promise}
- */
-export async function getContacts({ searchTerm }) {
-       const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
-               filter: searchTerm,
-       })
-       return contacts
-}
diff --git a/core/src/services/LegacyUnifiedSearchService.js b/core/src/services/LegacyUnifiedSearchService.js
new file mode 100644 (file)
index 0000000..3c67347
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
+ * @author Joas Schilling <coding@schilljs.com>
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * 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 { generateOcsUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
+
+export const defaultLimit = loadState('unified-search', 'limit-default')
+export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
+export const enableLiveSearch = loadState('unified-search', 'live-search', true)
+
+export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
+export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
+
+/**
+ * Create a cancel token
+ *
+ * @return {import('axios').CancelTokenSource}
+ */
+const createCancelToken = () => axios.CancelToken.source()
+
+/**
+ * Get the list of available search providers
+ *
+ * @return {Promise<Array>}
+ */
+export async function getTypes() {
+       try {
+               const { data } = await axios.get(generateOcsUrl('search/providers'), {
+                       params: {
+                               // Sending which location we're currently at
+                               from: window.location.pathname.replace('/index.php', '') + window.location.search,
+                       },
+               })
+               if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
+                       // Providers are sorted by the api based on their order key
+                       return data.ocs.data
+               }
+       } catch (error) {
+               console.error(error)
+       }
+       return []
+}
+
+/**
+ * Get the list of available search providers
+ *
+ * @param {object} options destructuring object
+ * @param {string} options.type the type to search
+ * @param {string} options.query the search
+ * @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @return {object} {request: Promise, cancel: Promise}
+ */
+export function search({ type, query, cursor }) {
+       /**
+        * Generate an axios cancel token
+        */
+       const cancelToken = createCancelToken()
+
+       const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
+               cancelToken: cancelToken.token,
+               params: {
+                       term: query,
+                       cursor,
+                       // Sending which location we're currently at
+                       from: window.location.pathname.replace('/index.php', '') + window.location.search,
+               },
+       })
+
+       return {
+               request,
+               cancel: cancelToken.cancel,
+       }
+}
index 3c673479771c0ea09da5507fbdf61c843db79866..e477a59eb4cf66f45aacd3fd4a0fa38687985f48 100644 (file)
@@ -1,10 +1,7 @@
 /**
- * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
+ * @copyright 2023, Fon E. Noel NFEBE <fenn25.fn@gmail.com>
  *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
  *
  * @license AGPL-3.0-or-later
  *
  *
  */
 
-import { generateOcsUrl } from '@nextcloud/router'
-import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
 import axios from '@nextcloud/axios'
 
-export const defaultLimit = loadState('unified-search', 'limit-default')
-export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
-export const enableLiveSearch = loadState('unified-search', 'live-search', true)
-
-export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
-export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
-
 /**
  * Create a cancel token
  *
@@ -46,7 +35,7 @@ const createCancelToken = () => axios.CancelToken.source()
  *
  * @return {Promise<Array>}
  */
-export async function getTypes() {
+export async function getProviders() {
        try {
                const { data } = await axios.get(generateOcsUrl('search/providers'), {
                        params: {
@@ -71,9 +60,13 @@ export async function getTypes() {
  * @param {string} options.type the type to search
  * @param {string} options.query the search
  * @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @param {string} options.since the search
+ * @param {string} options.until the search
+ * @param {string} options.limit the search
+ * @param {string} options.person the search
  * @return {object} {request: Promise, cancel: Promise}
  */
-export function search({ type, query, cursor }) {
+export function search({ type, query, cursor, since, until, limit, person }) {
        /**
         * Generate an axios cancel token
         */
@@ -84,6 +77,10 @@ export function search({ type, query, cursor }) {
                params: {
                        term: query,
                        cursor,
+                       since,
+                       until,
+                       limit,
+                       person,
                        // Sending which location we're currently at
                        from: window.location.pathname.replace('/index.php', '') + window.location.search,
                },
@@ -94,3 +91,17 @@ export function search({ type, query, cursor }) {
                cancel: cancelToken.cancel,
        }
 }
+
+/**
+ * Get the list of active contacts
+ *
+ * @param {object} filter filter contacts by string
+ * @param filter.searchTerm
+ * @return {object} {request: Promise}
+ */
+export async function getContacts({ searchTerm }) {
+       const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+               filter: searchTerm,
+       })
+       return contacts
+}
index cc390c0d6e71a3d45b3ad92ba011d4224c530910..f9bddff4c68372d41f78941e98610c00ba92aaf0 100644 (file)
@@ -1,7 +1,7 @@
 /**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ * @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
  *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
  *
  * @license AGPL-3.0-or-later
  *
diff --git a/core/src/views/GlobalSearch.vue b/core/src/views/GlobalSearch.vue
deleted file mode 100644 (file)
index 09e2d4f..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
- <!--
-  - @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
-  -
-  - @author Fon E. Noel NFEBE <fenn25.fn@gmail.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 class="header-menu">
-               <NcButton class="global-search__button" :aria-label="t('core', 'Unified search')" @click="toggleGlobalSearch">
-                       <template #icon>
-                               <Magnify class="global-search__trigger" :size="22" />
-                       </template>
-               </NcButton>
-               <GlobalSearchModal :class="'global-search-modal'" :is-visible="showGlobalSearch" @update:isVisible="handleModalVisibilityChange" />
-       </div>
-</template>
-
-<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import Magnify from 'vue-material-design-icons/Magnify.vue'
-import GlobalSearchModal from './GlobalSearchModal.vue'
-
-export default {
-       name: 'GlobalSearch',
-       components: {
-               NcButton,
-               Magnify,
-               GlobalSearchModal,
-       },
-       data() {
-               return {
-                       showGlobalSearch: false,
-               }
-       },
-       mounted() {
-               console.debug('Global search initialized!')
-       },
-       methods: {
-               toggleGlobalSearch() {
-                       this.showGlobalSearch = !this.showGlobalSearch
-               },
-               handleModalVisibilityChange(newVisibilityVal) {
-                       this.showGlobalSearch = newVisibilityVal
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-.header-menu {
-       display: flex;
-       align-items: center;
-       justify-content: center;
-
-       .global-search__button {
-               display: flex;
-               align-items: center;
-               justify-content: center;
-               width: var(--header-height);
-               // height: var(--header-height);
-               margin: 0;
-               padding: 0;
-               cursor: pointer;
-               opacity: .85;
-               background-color: transparent;
-               border: none;
-               filter: none !important;
-               color: var(--color-primary-text) !important;
-
-               &:hover {
-                       background-color: transparent !important;
-               }
-       }
-}
-
-.global-search-modal {
-       ::v-deep .modal-container {
-               height: 80%;
-       }
-}
-</style>
diff --git a/core/src/views/GlobalSearchModal.vue b/core/src/views/GlobalSearchModal.vue
deleted file mode 100644 (file)
index 6d9920a..0000000
+++ /dev/null
@@ -1,612 +0,0 @@
-<template>
-       <NcModal id="global-search"
-               ref="globalSearchModal"
-               :show.sync="internalIsVisible"
-               :clear-view-delay="0"
-               :title="t('Unified search')"
-               @close="closeModal">
-               <CustomDateRangeModal :is-open="showDateRangeModal"
-                       :class="'global-search__date-range'"
-                       @set:custom-date-range="setCustomDateRange"
-                       @update:is-open="showDateRangeModal = $event" />
-               <!-- Global search form -->
-               <div ref="globalSearch" class="global-search-modal">
-                       <h2 class="global-search-modal__heading">
-                               {{ t('core', 'Unified search') }}
-                       </h2>
-                       <NcInputField ref="searchInput"
-                               :value.sync="searchQuery"
-                               type="text"
-                               :label="t('core', 'Search apps, files, tags, messages') + '...'"
-                               @update:value="debouncedFind" />
-                       <div class="global-search-modal__filters">
-                               <NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
-                                       <template #icon>
-                                               <ListBox :size="20" />
-                                       </template>
-                                       <NcActionButton v-for="provider in providers" :key="provider.id" @click="addProviderFilter(provider)">
-                                               <template #icon>
-                                                       <img :src="provider.icon">
-                                               </template>
-                                               {{ t('core', provider.name) }}
-                                       </NcActionButton>
-                               </NcActions>
-                               <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen">
-                                       <template #icon>
-                                               <CalendarRangeIcon :size="20" />
-                                       </template>
-                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
-                                               {{ t('core', 'Today') }}
-                                       </NcActionButton>
-                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
-                                               {{ t('core', 'Last 7 days') }}
-                                       </NcActionButton>
-                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
-                                               {{ t('core', 'Last 30 days') }}
-                                       </NcActionButton>
-                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
-                                               {{ t('core', 'This year') }}
-                                       </NcActionButton>
-                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
-                                               {{ t('core', 'Last year') }}
-                                       </NcActionButton>
-                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
-                                               {{ t('core', 'Custom date range') }}
-                                       </NcActionButton>
-                               </NcActions>
-                               <SearchableList :label-text="t('core', 'Search people')"
-                                       :search-list="userContacts"
-                                       :empty-content-text="t('core', 'Not found')"
-                                       @item-selected="applyPersonFilter">
-                                       <template #trigger>
-                                               <NcButton>
-                                                       <template #icon>
-                                                               <AccountGroup :size="20" />
-                                                       </template>
-                                                       {{ t('core', 'People') }}
-                                               </NcButton>
-                                       </template>
-                               </SearchableList>
-                       </div>
-                       <div class="global-search-modal__filters-applied">
-                               <FilterChip v-for="filter in filters"
-                                       :key="filter.id"
-                                       :text="filter.name ?? filter.text"
-                                       :pretext="''"
-                                       @delete="removeFilter(filter)">
-                                       <template #icon>
-                                               <NcAvatar v-if="filter.type === 'person'"
-                                                       :user="filter.user"
-                                                       :size="24"
-                                                       :disable-menu="true"
-                                                       :show-user-status="false"
-                                                       :hide-favorite="false" />
-                                               <CalendarRangeIcon v-else-if="filter.type === 'date'" />
-                                               <img v-else :src="filter.icon" alt="">
-                                       </template>
-                               </FilterChip>
-                       </div>
-                       <div v-if="noContentInfo.show" class="global-search-modal__no-content">
-                               <NcEmptyContent :name="noContentInfo.text">
-                                       <template #icon>
-                                               <component :is="noContentInfo.icon" />
-                                       </template>
-                               </NcEmptyContent>
-                       </div>
-                       <div v-for="providerResult in results" :key="providerResult.id" class="global-search-modal__results">
-                               <div class="results">
-                                       <div class="result-title">
-                                               <span>{{ providerResult.provider }}</span>
-                                       </div>
-                                       <ul class="result-items">
-                                               <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" />
-                                       </ul>
-                                       <div class="result-footer">
-                                               <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
-                                                       {{ t('core', 'Load more results') }}
-                                                       <template #icon>
-                                                               <DotsHorizontalIcon :size="20" />
-                                                       </template>
-                                               </NcButton>
-                                               <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
-                                                       {{ t('core', 'Search in') }} {{ providerResult.provider }}
-                                                       <template #icon>
-                                                               <ArrowRight :size="20" />
-                                                       </template>
-                                               </NcButton>
-                                       </div>
-                               </div>
-                       </div>
-                       <div v-if="supportFiltering()" class="global-search-modal__results">
-                               <NcButton @click="closeModal">
-                                       {{ t('core', 'Filter in current view') }}
-                                       <template #icon>
-                                               <FilterIcon :size="20" />
-                                       </template>
-                               </NcButton>
-                       </div>
-               </div>
-       </NcModal>
-</template>
-
-<script>
-import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
-import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
-import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
-import CustomDateRangeModal from '../components/GlobalSearch/CustomDateRangeModal.vue'
-import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
-import FilterIcon from 'vue-material-design-icons/Filter.vue'
-import FilterChip from '../components/GlobalSearch/SearchFilterChip.vue'
-import ListBox from 'vue-material-design-icons/ListBox.vue'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
-import SearchableList from '../components/GlobalSearch/SearchableList.vue'
-import SearchResult from '../components/GlobalSearch/SearchResult.vue'
-
-import debounce from 'debounce'
-import { emit } from '@nextcloud/event-bus'
-import { getProviders, search as globalSearch, getContacts } from '../services/GlobalSearchService.js'
-
-export default {
-       name: 'GlobalSearchModal',
-       components: {
-               ArrowRight,
-               AccountGroup,
-               CalendarRangeIcon,
-               CustomDateRangeModal,
-               DotsHorizontalIcon,
-               FilterIcon,
-               FilterChip,
-               ListBox,
-               NcActions,
-               NcActionButton,
-               NcAvatar,
-               NcButton,
-               NcEmptyContent,
-               NcModal,
-               NcInputField,
-               MagnifyIcon,
-               SearchableList,
-               SearchResult,
-       },
-       props: {
-               isVisible: {
-                       type: Boolean,
-                       required: true,
-               },
-       },
-       data() {
-               return {
-                       providers: [],
-                       providerActionMenuIsOpen: false,
-                       dateActionMenuIsOpen: false,
-                       providerResultLimit: 5,
-                       dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
-                       personFilter: { id: 'person', type: 'person', name: '' },
-                       dateFilterIsApplied: false,
-                       personFilterIsApplied: false,
-                       filteredProviders: [],
-                       searching: false,
-                       searchQuery: '',
-                       placesFilter: '',
-                       dateTimeFilter: null,
-                       filters: [],
-                       results: [],
-                       contacts: [],
-                       debouncedFind: debounce(this.find, 300),
-                       showDateRangeModal: false,
-                       internalIsVisible: false,
-               }
-       },
-
-       computed: {
-               userContacts: {
-                       get() {
-                               return this.contacts
-                       },
-               },
-               noContentInfo: {
-                       get() {
-                               const isEmptySearch = this.searchQuery.length === 0
-                               const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0
-
-                               return {
-                                       show: isEmptySearch || hasNoResults,
-                                       text: this.searching && hasNoResults ? t('core', 'Searching â€¦') : (isEmptySearch ? t('core', 'Start typing in search') : t('core', 'No matching results')),
-                                       icon: MagnifyIcon,
-                               }
-                       },
-               },
-       },
-       watch: {
-               isVisible(value) {
-                       this.internalIsVisible = value
-               },
-               internalIsVisible(value) {
-                       this.$emit('update:isVisible', value)
-                       this.$nextTick(() => {
-                               if (value) {
-                                       this.focusInput()
-                               }
-                       })
-               },
-
-       },
-       mounted() {
-               getProviders().then((providers) => {
-                       this.providers = providers
-                       console.debug('Search providers', this.providers)
-               })
-               getContacts({ filter: '' }).then((contacts) => {
-                       this.contacts = this.mapContacts(contacts)
-                       console.debug('Contacts', this.contacts)
-               })
-       },
-       methods: {
-               find(query) {
-                       this.searching = true
-                       if (query.length === 0) {
-                               this.results = []
-                               this.searching = false
-                               return
-                       }
-                       // Event should probably be refactored at some point to used nextcloud:global-search.search
-                       emit('nextcloud:unified-search.search', { query })
-                       const newResults = []
-                       const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
-                       const searchProvider = (provider, filters) => {
-                               const params = {
-                                       type: provider.id,
-                                       query,
-                                       cursor: null,
-                               }
-
-                               if (filters.dateFilterIsApplied) {
-                                       if (provider.filters.since && provider.filters.until) {
-                                               params.since = this.dateFilter.startFrom
-                                               params.until = this.dateFilter.endAt
-                                       } else {
-                                               // Date filter is applied but provider does not support it, no need to search provider
-                                               return
-                                       }
-                               }
-
-                               if (filters.personFilterIsApplied) {
-                                       if (provider.filters.person) {
-                                               params.person = this.personFilter.user
-                                       } else {
-                                               // Person filter is applied but provider does not support it, no need to search provider
-                                               return
-                                       }
-                               }
-
-                               if (this.providerResultLimit > 5) {
-                                       params.limit = this.providerResultLimit
-                               }
-
-                               const request = globalSearch(params).request
-
-                               request().then((response) => {
-                                       newResults.push({
-                                               id: provider.id,
-                                               provider: provider.name,
-                                               inAppSearch: provider.inAppSearch,
-                                               results: response.data.ocs.data.entries,
-                                       })
-
-                                       console.debug('New results', newResults)
-                                       console.debug('Global search results:', this.results)
-
-                                       this.updateResults(newResults)
-                                       this.searching = false
-                               })
-                       }
-                       providersToSearch.forEach(provider => {
-                               const dateFilterIsApplied = this.dateFilterIsApplied
-                               const personFilterIsApplied = this.personFilterIsApplied
-                               searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
-                       })
-
-               },
-               updateResults(newResults) {
-                       let updatedResults = [...this.results]
-                       // If filters are applied, remove any previous results for providers that are not in current filters
-                       if (this.filters.length > 0) {
-                               updatedResults = updatedResults.filter(result => {
-                                       return this.filters.some(filter => filter.id === result.id)
-                               })
-                       }
-                       // Process the new results
-                       newResults.forEach(newResult => {
-                               const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
-                               if (existingResultIndex !== -1) {
-                                       if (newResult.results.length === 0) {
-                                               // If the new results data has no matches for and existing result, remove the existing result
-                                               updatedResults.splice(existingResultIndex, 1)
-                                       } else {
-                                               // If input triggered a change in existing results, update existing result
-                                               updatedResults.splice(existingResultIndex, 1, newResult)
-                                       }
-                               } else if (newResult.results.length > 0) {
-                                       // Push the new result to the array only if its results array is not empty
-                                       updatedResults.push(newResult)
-                               }
-                       })
-                       const sortedResults = updatedResults.slice(0)
-                       // Order results according to provider preference
-                       sortedResults.sort((a, b) => {
-                               const aProvider = this.providers.find(provider => provider.id === a.id)
-                               const bProvider = this.providers.find(provider => provider.id === b.id)
-                               const aOrder = aProvider ? aProvider.order : 0
-                               const bOrder = bProvider ? bProvider.order : 0
-                               return aOrder - bOrder
-                       })
-                       this.results = sortedResults
-               },
-               mapContacts(contacts) {
-                       return contacts.map(contact => {
-                               return {
-                                       // id: contact.id,
-                                       // name: '',
-                                       displayName: contact.fullName,
-                                       isNoUser: false,
-                                       subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
-                                       icon: '',
-                                       user: contact.id,
-                               }
-                       })
-               },
-               filterContacts(query) {
-                       getContacts({ filter: query }).then((contacts) => {
-                               this.contacts = this.mapContacts(contacts)
-                               console.debug(`Contacts filtered by ${query}`, this.contacts)
-                       })
-               },
-               applyPersonFilter(person) {
-                       this.personFilterIsApplied = true
-                       const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
-                       if (existingPersonFilter === -1) {
-                               this.personFilter.id = person.id
-                               this.personFilter.user = person.user
-                               this.personFilter.name = person.displayName
-                               this.filters.push(this.personFilter)
-                       } else {
-                               this.filters[existingPersonFilter].id = person.id
-                               this.filters[existingPersonFilter].user = person.user
-                               this.filters[existingPersonFilter].name = person.displayName
-                       }
-
-                       this.debouncedFind(this.searchQuery)
-                       console.debug('Person filter applied', person)
-               },
-               loadMoreResultsForProvider(providerId) {
-                       this.providerResultLimit += 5
-                       this.filters = this.filters.filter(filter => filter.type !== 'provider')
-                       const provider = this.providers.find(provider => provider.id === providerId)
-                       this.addProviderFilter(provider, true)
-               },
-               addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
-                       if (!providerFilter.id) return
-                       this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
-                       this.providerActionMenuIsOpen = false
-                       const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
-                       if (!existingFilter) {
-                               this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider', filters: providerFilter.filters })
-                       }
-                       this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
-                       console.debug('Search filters (newly added)', this.filters)
-                       this.debouncedFind(this.searchQuery)
-               },
-               removeFilter(filter) {
-                       if (filter.type === 'provider') {
-                               for (let i = 0; i < this.filteredProviders.length; i++) {
-                                       if (this.filteredProviders[i].id === filter.id) {
-                                               this.filteredProviders.splice(i, 1)
-                                               break
-                                       }
-                               }
-                               this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
-                               console.debug('Search filters (recently removed)', this.filters)
-
-                       } else {
-                               for (let i = 0; i < this.filters.length; i++) {
-                                       // Remove date and person filter
-                                       if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) {
-                                               this.dateFilterIsApplied = false
-                                               this.filters.splice(i, 1)
-                                               if (filter.type === 'person') {
-                                                       this.personFilterIsApplied = false
-                                               }
-                                               break
-                                       }
-                               }
-                       }
-                       this.debouncedFind(this.searchQuery)
-               },
-               syncProviderFilters(firstArray, secondArray) {
-                       // Create a copy of the first array to avoid modifying it directly.
-                       const synchronizedArray = firstArray.slice()
-                       // Remove items from the synchronizedArray that are not in the secondArray.
-                       synchronizedArray.forEach((item, index) => {
-                               const itemId = item.id
-                               if (item.type === 'provider') {
-                                       if (!secondArray.some(secondItem => secondItem.id === itemId)) {
-                                               synchronizedArray.splice(index, 1)
-                                       }
-                               }
-                       })
-                       // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
-                       secondArray.forEach(secondItem => {
-                               const itemId = secondItem.id
-                               if (secondItem.type === 'provider') {
-                                       if (!synchronizedArray.some(item => item.id === itemId)) {
-                                               synchronizedArray.push(secondItem)
-                                       }
-                               }
-                       })
-
-                       return synchronizedArray
-               },
-               updateDateFilter() {
-                       const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
-                       if (currFilterIndex !== -1) {
-                               this.filters[currFilterIndex] = this.dateFilter
-                       } else {
-                               this.filters.push(this.dateFilter)
-                       }
-                       this.dateFilterIsApplied = true
-                       this.debouncedFind(this.searchQuery)
-               },
-               applyQuickDateRange(range) {
-                       this.dateActionMenuIsOpen = false
-                       const today = new Date()
-                       let startDate
-                       let endDate
-
-                       switch (range) {
-                       case 'today':
-                               // For 'Today', both start and end are set to today
-                               startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
-                               endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
-                               this.dateFilter.text = t('core', 'Today')
-                               break
-                       case '7days':
-                               // For 'Last 7 days', start date is 7 days ago, end is today
-                               startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
-                               this.dateFilter.text = t('core', 'Last 7 days')
-                               break
-                       case '30days':
-                               // For 'Last 30 days', start date is 30 days ago, end is today
-                               startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
-                               this.dateFilter.text = t('core', 'Last 30 days')
-                               break
-                       case 'thisyear':
-                               // For 'This year', start date is the first day of the year, end is the last day of the year
-                               startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
-                               endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
-                               this.dateFilter.text = t('core', 'This year')
-                               break
-                       case 'lastyear':
-                               // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
-                               startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
-                               endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
-                               this.dateFilter.text = t('core', 'Last year')
-                               break
-                       case 'custom':
-                               this.showDateRangeModal = true
-                               return
-                       default:
-                               return
-                       }
-                       this.dateFilter.startFrom = startDate
-                       this.dateFilter.endAt = endDate
-                       this.updateDateFilter()
-
-               },
-               setCustomDateRange(event) {
-                       console.debug('Custom date range', event)
-                       this.dateFilter.startFrom = event.startFrom
-                       this.dateFilter.endAt = event.endAt
-                       this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
-                       this.updateDateFilter()
-               },
-               focusInput() {
-                       this.$refs.searchInput.$el.children[0].children[0].focus()
-               },
-               closeModal() {
-                       this.internalIsVisible = false
-                       this.searchQuery = ''
-               },
-               supportFiltering() {
-                       /* Hard coded apps for the moment this would be improved in coming updates. */
-                       const providerPaths = ['/settings/users', '/apps/files', '/apps/deck']
-                       const currentPath = window.location.pathname.replace('/index.php', '')
-                       const containsProvider = providerPaths.some(path => currentPath.includes(path))
-                       return containsProvider
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-.global-search-modal {
-       padding: 10px 20px 10px 20px;
-       height: 60%;
-
-       &__heading {
-               font-size: 16px;
-               font-weight: bolder;
-               line-height: 2em;
-               margin-bottom: 0;
-       }
-
-       &__filters {
-               display: flex;
-               padding-top: 4px;
-               justify-content: left;
-
-               >* {
-                       margin-right: 4px;
-
-               }
-
-       }
-
-       &__filters-applied {
-               padding-top: 4px;
-               display: flex;
-               flex-wrap: wrap;
-       }
-
-       &__no-content {
-               display: flex;
-               align-items: center;
-               height: 100%;
-       }
-
-       &__results {
-               padding: 10px;
-
-               .results {
-
-                       .result-title {
-                               span {
-                                       color: var(--color-primary-element);
-                                       font-weight: bolder;
-                                       font-size: 16px;
-                               }
-                       }
-
-                       .result-footer {
-                               justify-content: space-between;
-                               align-items: center;
-                               display: flex;
-                       }
-               }
-
-       }
-}
-
-div.v-popper__wrapper {
-       ul {
-               li {
-                       ::v-deep button.action-button {
-                               align-items: center !important;
-
-                               img {
-                                       width: 20px;
-                                       margin: 0 4px;
-                                       filter: var(--background-invert-if-bright);
-                               }
-
-                       }
-               }
-       }
-}
-</style>
diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue
new file mode 100644 (file)
index 0000000..04e4c77
--- /dev/null
@@ -0,0 +1,863 @@
+ <!--
+  - @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>
+       <NcHeaderMenu id="unified-search"
+               class="unified-search"
+               :exclude-click-outside-selectors="['.popover']"
+               :open.sync="open"
+               :aria-label="ariaLabel"
+               @open="onOpen"
+               @close="onClose">
+               <!-- Header icon -->
+               <template #trigger>
+                       <Magnify class="unified-search__trigger"
+                               :size="22/* fit better next to other 20px icons */" />
+               </template>
+
+               <!-- Search form & filters wrapper -->
+               <div class="unified-search__input-wrapper">
+                       <div class="unified-search__input-row">
+                               <NcTextField ref="input"
+                                       :value.sync="query"
+                                       trailing-button-icon="close"
+                                       :label="ariaLabel"
+                                       :trailing-button-label="t('core','Reset search')"
+                                       :show-trailing-button="query !== ''"
+                                       aria-describedby="unified-search-desc"
+                                       class="unified-search__form-input"
+                                       :class="{'unified-search__form-input--with-reset': !!query}"
+                                       :placeholder="t('core', 'Search {types} â€¦', { types: typesNames.join(', ') })"
+                                       @trailing-button-click="onReset"
+                                       @input="onInputDebounced" />
+                               <p id="unified-search-desc" class="hidden-visually">
+                                       {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
+                               </p>
+
+                               <!-- Search filters -->
+                               <NcActions v-if="availableFilters.length > 1"
+                                       class="unified-search__filters"
+                                       placement="bottom-end"
+                                       container=".unified-search__input-wrapper">
+                                       <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
+                                       <NcActionButton v-for="filter in availableFilters"
+                                               :key="filter"
+                                               icon="icon-filter"
+                                               @click.stop="onClickFilter(`in:${filter}`)">
+                                               {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
+                                       </NcActionButton>
+                               </NcActions>
+                       </div>
+               </div>
+
+               <template v-if="!hasResults">
+                       <!-- Loading placeholders -->
+                       <SearchResultPlaceholders v-if="isLoading" />
+
+                       <NcEmptyContent v-else-if="isValidQuery"
+                               :title="validQueryTitle">
+                               <template #icon>
+                                       <Magnify />
+                               </template>
+                       </NcEmptyContent>
+
+                       <NcEmptyContent v-else-if="!isLoading || isShortQuery"
+                               :title="t('core', 'Start typing to search')"
+                               :description="shortQueryDescription">
+                               <template #icon>
+                                       <Magnify />
+                               </template>
+                       </NcEmptyContent>
+               </template>
+
+               <!-- Grouped search results -->
+               <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
+                       <h2 :key="type" class="unified-search__results-header">
+                               {{ typesMap[type] }}
+                       </h2>
+                       <ul :key="type"
+                               class="unified-search__results"
+                               :class="`unified-search__results-${type}`"
+                               :aria-label="typesMap[type]">
+                               <!-- Search results -->
+                               <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
+                                       <SearchResult v-bind="result"
+                                               :query="query"
+                                               :focused="focused === 0 && typesIndex === 0 && index === 0"
+                                               @focus="setFocusedIndex" />
+                               </li>
+
+                               <!-- Load more button -->
+                               <li>
+                                       <SearchResult v-if="!reached[type]"
+                                               class="unified-search__result-more"
+                                               :title="loading[type]
+                                                       ? t('core', 'Loading more results â€¦')
+                                                       : t('core', 'Load more results')"
+                                               :icon-class="loading[type] ? 'icon-loading-small' : ''"
+                                               @click.prevent.stop="loadMore(type)"
+                                               @focus="setFocusedIndex" />
+                               </li>
+                       </ul>
+               </template>
+       </NcHeaderMenu>
+</template>
+
+<script>
+import debounce from 'debounce'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { showError } from '@nextcloud/dialogs'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
+import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
+import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
+
+import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js'
+
+const REQUEST_FAILED = 0
+const REQUEST_OK = 1
+const REQUEST_CANCELED = 2
+
+export default {
+       name: 'LegacyUnifiedSearch',
+
+       components: {
+               Magnify,
+               NcActionButton,
+               NcActions,
+               NcEmptyContent,
+               NcHeaderMenu,
+               SearchResult,
+               SearchResultPlaceholders,
+               NcTextField,
+       },
+
+       data() {
+               return {
+                       types: [],
+
+                       // Cursors per types
+                       cursors: {},
+                       // Various search limits per types
+                       limits: {},
+                       // Loading types
+                       loading: {},
+                       // Reached search types
+                       reached: {},
+                       // Pending cancellable requests
+                       requests: [],
+                       // List of all results
+                       results: {},
+
+                       query: '',
+                       focused: null,
+                       triggered: false,
+
+                       defaultLimit,
+                       minSearchLength,
+                       enableLiveSearch,
+
+                       open: false,
+               }
+       },
+
+       computed: {
+               typesIDs() {
+                       return this.types.map(type => type.id)
+               },
+               typesNames() {
+                       return this.types.map(type => type.name)
+               },
+               typesMap() {
+                       return this.types.reduce((prev, curr) => {
+                               prev[curr.id] = curr.name
+                               return prev
+                       }, {})
+               },
+
+               ariaLabel() {
+                       return t('core', 'Search')
+               },
+
+               /**
+                * Is there any result to display
+                *
+                * @return {boolean}
+                */
+               hasResults() {
+                       return Object.keys(this.results).length !== 0
+               },
+
+               /**
+                * Return ordered results
+                *
+                * @return {Array}
+                */
+               orderedResults() {
+                       return this.typesIDs
+                               .filter(type => type in this.results)
+                               .map(type => ({
+                                       type,
+                                       list: this.results[type],
+                               }))
+               },
+
+               /**
+                * Available filters
+                * We only show filters that are available on the results
+                *
+                * @return {string[]}
+                */
+               availableFilters() {
+                       return Object.keys(this.results)
+               },
+
+               /**
+                * Applied filters
+                *
+                * @return {string[]}
+                */
+               usedFiltersIn() {
+                       let match
+                       const filters = []
+                       while ((match = regexFilterIn.exec(this.query)) !== null) {
+                               filters.push(match[2])
+                       }
+                       return filters
+               },
+
+               /**
+                * Applied anti filters
+                *
+                * @return {string[]}
+                */
+               usedFiltersNot() {
+                       let match
+                       const filters = []
+                       while ((match = regexFilterNot.exec(this.query)) !== null) {
+                               filters.push(match[2])
+                       }
+                       return filters
+               },
+
+               /**
+                * Valid query empty content title
+                *
+                * @return {string}
+                */
+               validQueryTitle() {
+                       return this.triggered
+                               ? t('core', 'No results for {query}', { query: this.query })
+                               : t('core', 'Press Enter to start searching')
+               },
+
+               /**
+                * Short query empty content description
+                *
+                * @return {string}
+                */
+               shortQueryDescription() {
+                       if (!this.isShortQuery) {
+                               return ''
+                       }
+
+                       return n('core',
+                               'Please enter {minSearchLength} character or more to search',
+                               'Please enter {minSearchLength} characters  or more to search',
+                               this.minSearchLength,
+                               { minSearchLength: this.minSearchLength })
+               },
+
+               /**
+                * Is the current search too short
+                *
+                * @return {boolean}
+                */
+               isShortQuery() {
+                       return this.query && this.query.trim().length < minSearchLength
+               },
+
+               /**
+                * Is the current search valid
+                *
+                * @return {boolean}
+                */
+               isValidQuery() {
+                       return this.query && this.query.trim() !== '' && !this.isShortQuery
+               },
+
+               /**
+                * Have we reached the end of all types searches
+                *
+                * @return {boolean}
+                */
+               isDoneSearching() {
+                       return Object.values(this.reached).every(state => state === false)
+               },
+
+               /**
+                * Is there any search in progress
+                *
+                * @return {boolean}
+                */
+               isLoading() {
+                       return Object.values(this.loading).some(state => state === true)
+               },
+       },
+
+       async created() {
+               this.types = await getTypes()
+               this.logger.debug('Unified Search initialized with the following providers', this.types)
+       },
+
+       beforeDestroy() {
+               unsubscribe('files:navigation:changed', this.onNavigationChange)
+       },
+
+       mounted() {
+               // subscribe in mounted, as onNavigationChange relys on $el
+               subscribe('files:navigation:changed', this.onNavigationChange)
+
+               if (OCP.Accessibility.disableKeyboardShortcuts()) {
+                       return
+               }
+
+               document.addEventListener('keydown', (event) => {
+                       // if not already opened, allows us to trigger default browser on second keydown
+                       if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
+                               event.preventDefault()
+                               this.open = true
+                       } else if (event.ctrlKey && event.key === 'f' && this.open) {
+                               // User wants to use the native browser search, so we close ours again
+                               this.open = false
+                       }
+
+                       // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
+                       if (this.open) {
+                               // If arrow down, focus next result
+                               if (event.key === 'ArrowDown') {
+                                       this.focusNext(event)
+                               }
+
+                               // If arrow up, focus prev result
+                               if (event.key === 'ArrowUp') {
+                                       this.focusPrev(event)
+                               }
+                       }
+               })
+       },
+
+       methods: {
+               async onOpen() {
+                       // Update types list in the background
+                       this.types = await getTypes()
+               },
+               onClose() {
+                       emit('nextcloud:unified-search.close')
+               },
+
+               onNavigationChange() {
+                       this.$el?.querySelector?.('form[role="search"]')?.reset?.()
+               },
+
+               /**
+                * Reset the search state
+                */
+               onReset() {
+                       emit('nextcloud:unified-search.reset')
+                       this.logger.debug('Search reset')
+                       this.query = ''
+                       this.resetState()
+                       this.focusInput()
+               },
+               async resetState() {
+                       this.cursors = {}
+                       this.limits = {}
+                       this.reached = {}
+                       this.results = {}
+                       this.focused = null
+                       this.triggered = false
+                       await this.cancelPendingRequests()
+               },
+
+               /**
+                * Cancel any ongoing searches
+                */
+               async cancelPendingRequests() {
+                       // Cloning so we can keep processing other requests
+                       const requests = this.requests.slice(0)
+                       this.requests = []
+
+                       // Cancel all pending requests
+                       await Promise.all(requests.map(cancel => cancel()))
+               },
+
+               /**
+                * Focus the search input on next tick
+                */
+               focusInput() {
+                       this.$nextTick(() => {
+                               this.$refs.input.focus()
+                               this.$refs.input.select()
+                       })
+               },
+
+               /**
+                * If we have results already, open first one
+                * If not, trigger the search again
+                */
+               onInputEnter() {
+                       if (this.hasResults) {
+                               const results = this.getResultsList()
+                               results[0].click()
+                               return
+                       }
+                       this.onInput()
+               },
+
+               /**
+                * Start searching on input
+                */
+               async onInput() {
+                       // emit the search query
+                       emit('nextcloud:unified-search.search', { query: this.query })
+
+                       // Do not search if not long enough
+                       if (this.query.trim() === '' || this.isShortQuery) {
+                               for (const type of this.typesIDs) {
+                                       this.$delete(this.results, type)
+                               }
+                               return
+                       }
+
+                       let types = this.typesIDs
+                       let query = this.query
+
+                       // Filter out types
+                       if (this.usedFiltersNot.length > 0) {
+                               types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
+                       }
+
+                       // Only use those filters if any and check if they are valid
+                       if (this.usedFiltersIn.length > 0) {
+                               types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
+                       }
+
+                       // Remove any filters from the query
+                       query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
+
+                       // Reset search if the query changed
+                       await this.resetState()
+                       this.triggered = true
+
+                       if (!types.length) {
+                               // no results since no types were selected
+                               this.logger.error('No types to search in')
+                               return
+                       }
+
+                       this.$set(this.loading, 'all', true)
+                       this.logger.debug(`Searching ${query} in`, types)
+
+                       Promise.all(types.map(async type => {
+                               try {
+                                       // Init cancellable request
+                                       const { request, cancel } = search({ type, query })
+                                       this.requests.push(cancel)
+
+                                       // Fetch results
+                                       const { data } = await request()
+
+                                       // Process results
+                                       if (data.ocs.data.entries.length > 0) {
+                                               this.$set(this.results, type, data.ocs.data.entries)
+                                       } else {
+                                               this.$delete(this.results, type)
+                                       }
+
+                                       // Save cursor if any
+                                       if (data.ocs.data.cursor) {
+                                               this.$set(this.cursors, type, data.ocs.data.cursor)
+                                       } else if (!data.ocs.data.isPaginated) {
+                                       // If no cursor and no pagination, we save the default amount
+                                       // provided by server's initial state `defaultLimit`
+                                               this.$set(this.limits, type, this.defaultLimit)
+                                       }
+
+                                       // Check if we reached end of pagination
+                                       if (data.ocs.data.entries.length < this.defaultLimit) {
+                                               this.$set(this.reached, type, true)
+                                       }
+
+                                       // If none already focused, focus the first rendered result
+                                       if (this.focused === null) {
+                                               this.focused = 0
+                                       }
+                                       return REQUEST_OK
+                               } catch (error) {
+                                       this.$delete(this.results, type)
+
+                                       // If this is not a cancelled throw
+                                       if (error.response && error.response.status) {
+                                               this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
+                                               showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
+                                               return REQUEST_FAILED
+                                       }
+                                       return REQUEST_CANCELED
+                               }
+                       })).then(results => {
+                               // Do not declare loading finished if the request have been cancelled
+                               // This means another search was triggered and we're therefore still loading
+                               if (results.some(result => result === REQUEST_CANCELED)) {
+                                       return
+                               }
+                               // We finished all searches
+                               this.loading = {}
+                       })
+               },
+               onInputDebounced: enableLiveSearch
+                       ? debounce(function(e) {
+                               this.onInput(e)
+                       }, 500)
+                       : function() {
+                               this.triggered = false
+                       },
+
+               /**
+                * Load more results for the provided type
+                *
+                * @param {string} type type
+                */
+               async loadMore(type) {
+                       // If already loading, ignore
+                       if (this.loading[type]) {
+                               return
+                       }
+
+                       if (this.cursors[type]) {
+                               // Init cancellable request
+                               const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
+                               this.requests.push(cancel)
+
+                               // Fetch results
+                               const { data } = await request()
+
+                               // Save cursor if any
+                               if (data.ocs.data.cursor) {
+                                       this.$set(this.cursors, type, data.ocs.data.cursor)
+                               }
+
+                               // Process results
+                               if (data.ocs.data.entries.length > 0) {
+                                       this.results[type].push(...data.ocs.data.entries)
+                               }
+
+                               // Check if we reached end of pagination
+                               if (data.ocs.data.entries.length < this.defaultLimit) {
+                                       this.$set(this.reached, type, true)
+                               }
+                       } else {
+                               // If no cursor, we might have all the results already,
+                               // let's fake pagination and show the next xxx entries
+                               if (this.limits[type] && this.limits[type] >= 0) {
+                                       this.limits[type] += this.defaultLimit
+
+                                       // Check if we reached end of pagination
+                                       if (this.limits[type] >= this.results[type].length) {
+                                               this.$set(this.reached, type, true)
+                                       }
+                               }
+                       }
+
+                       // Focus result after render
+                       if (this.focused !== null) {
+                               this.$nextTick(() => {
+                                       this.focusIndex(this.focused)
+                               })
+                       }
+               },
+
+               /**
+                * Return a subset of the array if the search provider
+                * doesn't supports pagination
+                *
+                * @param {Array} list the results
+                * @param {string} type the type
+                * @return {Array}
+                */
+               limitIfAny(list, type) {
+                       if (type in this.limits) {
+                               return list.slice(0, this.limits[type])
+                       }
+                       return list
+               },
+
+               getResultsList() {
+                       return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
+               },
+
+               /**
+                * Focus the first result if any
+                *
+                * @param {Event} event the keydown event
+                */
+               focusFirst(event) {
+                       const results = this.getResultsList()
+                       if (results && results.length > 0) {
+                               if (event) {
+                                       event.preventDefault()
+                               }
+                               this.focused = 0
+                               this.focusIndex(this.focused)
+                       }
+               },
+
+               /**
+                * Focus the next result if any
+                *
+                * @param {Event} event the keydown event
+                */
+               focusNext(event) {
+                       if (this.focused === null) {
+                               this.focusFirst(event)
+                               return
+                       }
+
+                       const results = this.getResultsList()
+                       // If we're not focusing the last, focus the next one
+                       if (results && results.length > 0 && this.focused + 1 < results.length) {
+                               event.preventDefault()
+                               this.focused++
+                               this.focusIndex(this.focused)
+                       }
+               },
+
+               /**
+                * Focus the previous result if any
+                *
+                * @param {Event} event the keydown event
+                */
+               focusPrev(event) {
+                       if (this.focused === null) {
+                               this.focusFirst(event)
+                               return
+                       }
+
+                       const results = this.getResultsList()
+                       // If we're not focusing the first, focus the previous one
+                       if (results && results.length > 0 && this.focused > 0) {
+                               event.preventDefault()
+                               this.focused--
+                               this.focusIndex(this.focused)
+                       }
+
+               },
+
+               /**
+                * Focus the specified result index if it exists
+                *
+                * @param {number} index the result index
+                */
+               focusIndex(index) {
+                       const results = this.getResultsList()
+                       if (results && results[index]) {
+                               results[index].focus()
+                       }
+               },
+
+               /**
+                * Set the current focused element based on the target
+                *
+                * @param {Event} event the focus event
+                */
+               setFocusedIndex(event) {
+                       const entry = event.target
+                       const results = this.getResultsList()
+                       const index = [...results].findIndex(search => search === entry)
+                       if (index > -1) {
+                               // let's not use focusIndex as the entry is already focused
+                               this.focused = index
+                       }
+               },
+
+               onClickFilter(filter) {
+                       this.query = `${this.query} ${filter}`
+                               .replace(/ {2}/g, ' ')
+                               .trim()
+                       this.onInput()
+               },
+       },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$margin: 10px;
+$input-height: 34px;
+$input-padding: 10px;
+
+.unified-search {
+       &__input-wrapper {
+               position: sticky;
+               // above search results
+               z-index: 2;
+               top: 0;
+               display: inline-flex;
+               flex-direction: column;
+               align-items: center;
+               width: 100%;
+               background-color: var(--color-main-background);
+
+               label[for="unified-search__input"] {
+                       align-self: flex-start;
+                       font-weight: bold;
+                       font-size: 19px;
+                       margin-left: 13px;
+               }
+       }
+
+       &__form-input {
+               margin: 0 !important;
+               &:focus,
+               &:focus-visible,
+               &:active {
+                       border-color: 2px solid var(--color-main-text) !important;
+                       box-shadow: 0 0 0 2px var(--color-main-background) !important;
+               }
+       }
+
+       &__input-row {
+               display: flex;
+               width: 100%;
+               align-items: center;
+       }
+
+       &__filters {
+               margin: $margin 0 $margin math.div($margin, 2);
+               padding-top: 5px;
+               ul {
+                       display: inline-flex;
+                       justify-content: space-between;
+               }
+       }
+
+       &__form {
+               position: relative;
+               width: 100%;
+               margin: $margin 0;
+
+               // Loading spinner
+               &::after {
+                       right: $input-padding;
+                       left: auto;
+               }
+
+               &-input,
+               &-reset {
+                       margin: math.div($input-padding, 2);
+               }
+
+               &-input {
+                       width: 100%;
+                       height: $input-height;
+                       padding: $input-padding;
+
+                       &,
+                       &[placeholder],
+                       &::placeholder {
+                               overflow: hidden;
+                               white-space: nowrap;
+                               text-overflow: ellipsis;
+                       }
+
+                       // Hide webkit clear search
+                       &::-webkit-search-decoration,
+                       &::-webkit-search-cancel-button,
+                       &::-webkit-search-results-button,
+                       &::-webkit-search-results-decoration {
+                               -webkit-appearance: none;
+                       }
+               }
+
+               &-reset, &-submit {
+                       position: absolute;
+                       top: 0;
+                       right: 4px;
+                       width: $input-height - $input-padding;
+                       height: $input-height - $input-padding;
+                       min-height: 30px;
+                       padding: 0;
+                       opacity: .5;
+                       border: none;
+                       background-color: transparent;
+                       margin-right: 0;
+
+                       &:hover,
+                       &:focus,
+                       &:active {
+                               opacity: 1;
+                       }
+               }
+
+               &-submit {
+                       right: 28px;
+               }
+       }
+
+       &__results {
+               &-header {
+                       display: block;
+                       margin: $margin;
+                       margin-bottom: $margin - 4px;
+                       margin-left: 13px;
+                       color: var(--color-primary-element);
+                       font-size: 19px;
+                       font-weight: bold;
+               }
+               display: flex;
+               flex-direction: column;
+               gap: 4px;
+       }
+
+       .unified-search__result-more::v-deep {
+               color: var(--color-text-maxcontrast);
+       }
+
+       .empty-content {
+               margin: 10vh 0;
+
+               ::v-deep .empty-content__title {
+                       font-weight: normal;
+            font-size: var(--default-font-size);
+                       text-align: center;
+               }
+       }
+}
+
+</style>
index 6bbaf8bca774c823e176f3f93cff5ee3fb9ad8d5..419c0b47c412fd8d7b25b863ad35c0e62155bfe9 100644 (file)
@@ -1,7 +1,7 @@
  <!--
-  - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+  - @copyright Copyright (c) 2020 Fon E. Noel NFEBE <fenn25.fn@gmail.com>
   -
-  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  - @author Fon E. Noel NFEBE <fenn25.fn@gmail.com>
   -
   - @license GNU AGPL version 3 or any later version
   -
   -
   -->
 <template>
-       <NcHeaderMenu id="unified-search"
-               class="unified-search"
-               :exclude-click-outside-selectors="['.popover']"
-               :open.sync="open"
-               :aria-label="ariaLabel"
-               @open="onOpen"
-               @close="onClose">
-               <!-- Header icon -->
-               <template #trigger>
-                       <Magnify class="unified-search__trigger"
-                               :size="22/* fit better next to other 20px icons */" />
-               </template>
-
-               <!-- Search form & filters wrapper -->
-               <div class="unified-search__input-wrapper">
-                       <div class="unified-search__input-row">
-                               <NcTextField ref="input"
-                                       :value.sync="query"
-                                       trailing-button-icon="close"
-                                       :label="ariaLabel"
-                                       :trailing-button-label="t('core','Reset search')"
-                                       :show-trailing-button="query !== ''"
-                                       aria-describedby="unified-search-desc"
-                                       class="unified-search__form-input"
-                                       :class="{'unified-search__form-input--with-reset': !!query}"
-                                       :placeholder="t('core', 'Search {types} â€¦', { types: typesNames.join(', ') })"
-                                       @trailing-button-click="onReset"
-                                       @input="onInputDebounced" />
-                               <p id="unified-search-desc" class="hidden-visually">
-                                       {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
-                               </p>
-
-                               <!-- Search filters -->
-                               <NcActions v-if="availableFilters.length > 1"
-                                       class="unified-search__filters"
-                                       placement="bottom-end"
-                                       container=".unified-search__input-wrapper">
-                                       <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
-                                       <NcActionButton v-for="filter in availableFilters"
-                                               :key="filter"
-                                               icon="icon-filter"
-                                               @click.stop="onClickFilter(`in:${filter}`)">
-                                               {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
-                                       </NcActionButton>
-                               </NcActions>
-                       </div>
-               </div>
-
-               <template v-if="!hasResults">
-                       <!-- Loading placeholders -->
-                       <SearchResultPlaceholders v-if="isLoading" />
-
-                       <NcEmptyContent v-else-if="isValidQuery"
-                               :title="validQueryTitle">
-                               <template #icon>
-                                       <Magnify />
-                               </template>
-                       </NcEmptyContent>
-
-                       <NcEmptyContent v-else-if="!isLoading || isShortQuery"
-                               :title="t('core', 'Start typing to search')"
-                               :description="shortQueryDescription">
-                               <template #icon>
-                                       <Magnify />
-                               </template>
-                       </NcEmptyContent>
-               </template>
-
-               <!-- Grouped search results -->
-               <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
-                       <h2 :key="type" class="unified-search__results-header">
-                               {{ typesMap[type] }}
-                       </h2>
-                       <ul :key="type"
-                               class="unified-search__results"
-                               :class="`unified-search__results-${type}`"
-                               :aria-label="typesMap[type]">
-                               <!-- Search results -->
-                               <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
-                                       <SearchResult v-bind="result"
-                                               :query="query"
-                                               :focused="focused === 0 && typesIndex === 0 && index === 0"
-                                               @focus="setFocusedIndex" />
-                               </li>
-
-                               <!-- Load more button -->
-                               <li>
-                                       <SearchResult v-if="!reached[type]"
-                                               class="unified-search__result-more"
-                                               :title="loading[type]
-                                                       ? t('core', 'Loading more results â€¦')
-                                                       : t('core', 'Load more results')"
-                                               :icon-class="loading[type] ? 'icon-loading-small' : ''"
-                                               @click.prevent.stop="loadMore(type)"
-                                               @focus="setFocusedIndex" />
-                               </li>
-                       </ul>
-               </template>
-       </NcHeaderMenu>
+       <div class="header-menu">
+               <NcButton class="unified-search__button" :aria-label="t('core', 'Unified search')" @click="toggleUnifiedSearch">
+                       <template #icon>
+                               <Magnify class="unified-search__trigger" :size="22" />
+                       </template>
+               </NcButton>
+               <UnifiedSearchModal :class="'unified-search-modal'" :is-visible="showUnifiedSearch" @update:isVisible="handleModalVisibilityChange" />
+       </div>
 </template>
 
 <script>
-import debounce from 'debounce'
-import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { showError } from '@nextcloud/dialogs'
-
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
-
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
 import Magnify from 'vue-material-design-icons/Magnify.vue'
-
-import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
-import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
-
-import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/UnifiedSearchService.js'
-
-const REQUEST_FAILED = 0
-const REQUEST_OK = 1
-const REQUEST_CANCELED = 2
+import UnifiedSearchModal from './UnifiedSearchModal.vue'
 
 export default {
        name: 'UnifiedSearch',
-
        components: {
+               NcButton,
                Magnify,
-               NcActionButton,
-               NcActions,
-               NcEmptyContent,
-               NcHeaderMenu,
-               SearchResult,
-               SearchResultPlaceholders,
-               NcTextField,
+               UnifiedSearchModal,
        },
-
        data() {
                return {
-                       types: [],
-
-                       // Cursors per types
-                       cursors: {},
-                       // Various search limits per types
-                       limits: {},
-                       // Loading types
-                       loading: {},
-                       // Reached search types
-                       reached: {},
-                       // Pending cancellable requests
-                       requests: [],
-                       // List of all results
-                       results: {},
-
-                       query: '',
-                       focused: null,
-                       triggered: false,
-
-                       defaultLimit,
-                       minSearchLength,
-                       enableLiveSearch,
-
-                       open: false,
+                       showUnifiedSearch: false,
                }
        },
-
-       computed: {
-               typesIDs() {
-                       return this.types.map(type => type.id)
-               },
-               typesNames() {
-                       return this.types.map(type => type.name)
-               },
-               typesMap() {
-                       return this.types.reduce((prev, curr) => {
-                               prev[curr.id] = curr.name
-                               return prev
-                       }, {})
-               },
-
-               ariaLabel() {
-                       return t('core', 'Search')
-               },
-
-               /**
-                * Is there any result to display
-                *
-                * @return {boolean}
-                */
-               hasResults() {
-                       return Object.keys(this.results).length !== 0
-               },
-
-               /**
-                * Return ordered results
-                *
-                * @return {Array}
-                */
-               orderedResults() {
-                       return this.typesIDs
-                               .filter(type => type in this.results)
-                               .map(type => ({
-                                       type,
-                                       list: this.results[type],
-                               }))
-               },
-
-               /**
-                * Available filters
-                * We only show filters that are available on the results
-                *
-                * @return {string[]}
-                */
-               availableFilters() {
-                       return Object.keys(this.results)
-               },
-
-               /**
-                * Applied filters
-                *
-                * @return {string[]}
-                */
-               usedFiltersIn() {
-                       let match
-                       const filters = []
-                       while ((match = regexFilterIn.exec(this.query)) !== null) {
-                               filters.push(match[2])
-                       }
-                       return filters
-               },
-
-               /**
-                * Applied anti filters
-                *
-                * @return {string[]}
-                */
-               usedFiltersNot() {
-                       let match
-                       const filters = []
-                       while ((match = regexFilterNot.exec(this.query)) !== null) {
-                               filters.push(match[2])
-                       }
-                       return filters
-               },
-
-               /**
-                * Valid query empty content title
-                *
-                * @return {string}
-                */
-               validQueryTitle() {
-                       return this.triggered
-                               ? t('core', 'No results for {query}', { query: this.query })
-                               : t('core', 'Press Enter to start searching')
-               },
-
-               /**
-                * Short query empty content description
-                *
-                * @return {string}
-                */
-               shortQueryDescription() {
-                       if (!this.isShortQuery) {
-                               return ''
-                       }
-
-                       return n('core',
-                               'Please enter {minSearchLength} character or more to search',
-                               'Please enter {minSearchLength} characters  or more to search',
-                               this.minSearchLength,
-                               { minSearchLength: this.minSearchLength })
-               },
-
-               /**
-                * Is the current search too short
-                *
-                * @return {boolean}
-                */
-               isShortQuery() {
-                       return this.query && this.query.trim().length < minSearchLength
-               },
-
-               /**
-                * Is the current search valid
-                *
-                * @return {boolean}
-                */
-               isValidQuery() {
-                       return this.query && this.query.trim() !== '' && !this.isShortQuery
-               },
-
-               /**
-                * Have we reached the end of all types searches
-                *
-                * @return {boolean}
-                */
-               isDoneSearching() {
-                       return Object.values(this.reached).every(state => state === false)
-               },
-
-               /**
-                * Is there any search in progress
-                *
-                * @return {boolean}
-                */
-               isLoading() {
-                       return Object.values(this.loading).some(state => state === true)
-               },
-       },
-
-       async created() {
-               this.types = await getTypes()
-               this.logger.debug('Unified Search initialized with the following providers', this.types)
-       },
-
-       beforeDestroy() {
-               unsubscribe('files:navigation:changed', this.onNavigationChange)
-       },
-
        mounted() {
-               // subscribe in mounted, as onNavigationChange relys on $el
-               subscribe('files:navigation:changed', this.onNavigationChange)
-
-               if (OCP.Accessibility.disableKeyboardShortcuts()) {
-                       return
-               }
-
-               document.addEventListener('keydown', (event) => {
-                       // if not already opened, allows us to trigger default browser on second keydown
-                       if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
-                               event.preventDefault()
-                               this.open = true
-                       } else if (event.ctrlKey && event.key === 'f' && this.open) {
-                               // User wants to use the native browser search, so we close ours again
-                               this.open = false
-                       }
-
-                       // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
-                       if (this.open) {
-                               // If arrow down, focus next result
-                               if (event.key === 'ArrowDown') {
-                                       this.focusNext(event)
-                               }
-
-                               // If arrow up, focus prev result
-                               if (event.key === 'ArrowUp') {
-                                       this.focusPrev(event)
-                               }
-                       }
-               })
+               console.debug('Unified search initialized!')
        },
-
        methods: {
-               async onOpen() {
-                       // Update types list in the background
-                       this.types = await getTypes()
-               },
-               onClose() {
-                       emit('nextcloud:unified-search.close')
-               },
-
-               onNavigationChange() {
-                       this.$el?.querySelector?.('form[role="search"]')?.reset?.()
-               },
-
-               /**
-                * Reset the search state
-                */
-               onReset() {
-                       emit('nextcloud:unified-search.reset')
-                       this.logger.debug('Search reset')
-                       this.query = ''
-                       this.resetState()
-                       this.focusInput()
-               },
-               async resetState() {
-                       this.cursors = {}
-                       this.limits = {}
-                       this.reached = {}
-                       this.results = {}
-                       this.focused = null
-                       this.triggered = false
-                       await this.cancelPendingRequests()
-               },
-
-               /**
-                * Cancel any ongoing searches
-                */
-               async cancelPendingRequests() {
-                       // Cloning so we can keep processing other requests
-                       const requests = this.requests.slice(0)
-                       this.requests = []
-
-                       // Cancel all pending requests
-                       await Promise.all(requests.map(cancel => cancel()))
-               },
-
-               /**
-                * Focus the search input on next tick
-                */
-               focusInput() {
-                       this.$nextTick(() => {
-                               this.$refs.input.focus()
-                               this.$refs.input.select()
-                       })
-               },
-
-               /**
-                * If we have results already, open first one
-                * If not, trigger the search again
-                */
-               onInputEnter() {
-                       if (this.hasResults) {
-                               const results = this.getResultsList()
-                               results[0].click()
-                               return
-                       }
-                       this.onInput()
-               },
-
-               /**
-                * Start searching on input
-                */
-               async onInput() {
-                       // emit the search query
-                       emit('nextcloud:unified-search.search', { query: this.query })
-
-                       // Do not search if not long enough
-                       if (this.query.trim() === '' || this.isShortQuery) {
-                               for (const type of this.typesIDs) {
-                                       this.$delete(this.results, type)
-                               }
-                               return
-                       }
-
-                       let types = this.typesIDs
-                       let query = this.query
-
-                       // Filter out types
-                       if (this.usedFiltersNot.length > 0) {
-                               types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
-                       }
-
-                       // Only use those filters if any and check if they are valid
-                       if (this.usedFiltersIn.length > 0) {
-                               types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
-                       }
-
-                       // Remove any filters from the query
-                       query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
-
-                       // Reset search if the query changed
-                       await this.resetState()
-                       this.triggered = true
-
-                       if (!types.length) {
-                               // no results since no types were selected
-                               this.logger.error('No types to search in')
-                               return
-                       }
-
-                       this.$set(this.loading, 'all', true)
-                       this.logger.debug(`Searching ${query} in`, types)
-
-                       Promise.all(types.map(async type => {
-                               try {
-                                       // Init cancellable request
-                                       const { request, cancel } = search({ type, query })
-                                       this.requests.push(cancel)
-
-                                       // Fetch results
-                                       const { data } = await request()
-
-                                       // Process results
-                                       if (data.ocs.data.entries.length > 0) {
-                                               this.$set(this.results, type, data.ocs.data.entries)
-                                       } else {
-                                               this.$delete(this.results, type)
-                                       }
-
-                                       // Save cursor if any
-                                       if (data.ocs.data.cursor) {
-                                               this.$set(this.cursors, type, data.ocs.data.cursor)
-                                       } else if (!data.ocs.data.isPaginated) {
-                                       // If no cursor and no pagination, we save the default amount
-                                       // provided by server's initial state `defaultLimit`
-                                               this.$set(this.limits, type, this.defaultLimit)
-                                       }
-
-                                       // Check if we reached end of pagination
-                                       if (data.ocs.data.entries.length < this.defaultLimit) {
-                                               this.$set(this.reached, type, true)
-                                       }
-
-                                       // If none already focused, focus the first rendered result
-                                       if (this.focused === null) {
-                                               this.focused = 0
-                                       }
-                                       return REQUEST_OK
-                               } catch (error) {
-                                       this.$delete(this.results, type)
-
-                                       // If this is not a cancelled throw
-                                       if (error.response && error.response.status) {
-                                               this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
-                                               showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
-                                               return REQUEST_FAILED
-                                       }
-                                       return REQUEST_CANCELED
-                               }
-                       })).then(results => {
-                               // Do not declare loading finished if the request have been cancelled
-                               // This means another search was triggered and we're therefore still loading
-                               if (results.some(result => result === REQUEST_CANCELED)) {
-                                       return
-                               }
-                               // We finished all searches
-                               this.loading = {}
-                       })
-               },
-               onInputDebounced: enableLiveSearch
-                       ? debounce(function(e) {
-                               this.onInput(e)
-                       }, 500)
-                       : function() {
-                               this.triggered = false
-                       },
-
-               /**
-                * Load more results for the provided type
-                *
-                * @param {string} type type
-                */
-               async loadMore(type) {
-                       // If already loading, ignore
-                       if (this.loading[type]) {
-                               return
-                       }
-
-                       if (this.cursors[type]) {
-                               // Init cancellable request
-                               const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
-                               this.requests.push(cancel)
-
-                               // Fetch results
-                               const { data } = await request()
-
-                               // Save cursor if any
-                               if (data.ocs.data.cursor) {
-                                       this.$set(this.cursors, type, data.ocs.data.cursor)
-                               }
-
-                               // Process results
-                               if (data.ocs.data.entries.length > 0) {
-                                       this.results[type].push(...data.ocs.data.entries)
-                               }
-
-                               // Check if we reached end of pagination
-                               if (data.ocs.data.entries.length < this.defaultLimit) {
-                                       this.$set(this.reached, type, true)
-                               }
-                       } else {
-                               // If no cursor, we might have all the results already,
-                               // let's fake pagination and show the next xxx entries
-                               if (this.limits[type] && this.limits[type] >= 0) {
-                                       this.limits[type] += this.defaultLimit
-
-                                       // Check if we reached end of pagination
-                                       if (this.limits[type] >= this.results[type].length) {
-                                               this.$set(this.reached, type, true)
-                                       }
-                               }
-                       }
-
-                       // Focus result after render
-                       if (this.focused !== null) {
-                               this.$nextTick(() => {
-                                       this.focusIndex(this.focused)
-                               })
-                       }
-               },
-
-               /**
-                * Return a subset of the array if the search provider
-                * doesn't supports pagination
-                *
-                * @param {Array} list the results
-                * @param {string} type the type
-                * @return {Array}
-                */
-               limitIfAny(list, type) {
-                       if (type in this.limits) {
-                               return list.slice(0, this.limits[type])
-                       }
-                       return list
-               },
-
-               getResultsList() {
-                       return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
-               },
-
-               /**
-                * Focus the first result if any
-                *
-                * @param {Event} event the keydown event
-                */
-               focusFirst(event) {
-                       const results = this.getResultsList()
-                       if (results && results.length > 0) {
-                               if (event) {
-                                       event.preventDefault()
-                               }
-                               this.focused = 0
-                               this.focusIndex(this.focused)
-                       }
-               },
-
-               /**
-                * Focus the next result if any
-                *
-                * @param {Event} event the keydown event
-                */
-               focusNext(event) {
-                       if (this.focused === null) {
-                               this.focusFirst(event)
-                               return
-                       }
-
-                       const results = this.getResultsList()
-                       // If we're not focusing the last, focus the next one
-                       if (results && results.length > 0 && this.focused + 1 < results.length) {
-                               event.preventDefault()
-                               this.focused++
-                               this.focusIndex(this.focused)
-                       }
-               },
-
-               /**
-                * Focus the previous result if any
-                *
-                * @param {Event} event the keydown event
-                */
-               focusPrev(event) {
-                       if (this.focused === null) {
-                               this.focusFirst(event)
-                               return
-                       }
-
-                       const results = this.getResultsList()
-                       // If we're not focusing the first, focus the previous one
-                       if (results && results.length > 0 && this.focused > 0) {
-                               event.preventDefault()
-                               this.focused--
-                               this.focusIndex(this.focused)
-                       }
-
-               },
-
-               /**
-                * Focus the specified result index if it exists
-                *
-                * @param {number} index the result index
-                */
-               focusIndex(index) {
-                       const results = this.getResultsList()
-                       if (results && results[index]) {
-                               results[index].focus()
-                       }
-               },
-
-               /**
-                * Set the current focused element based on the target
-                *
-                * @param {Event} event the focus event
-                */
-               setFocusedIndex(event) {
-                       const entry = event.target
-                       const results = this.getResultsList()
-                       const index = [...results].findIndex(search => search === entry)
-                       if (index > -1) {
-                               // let's not use focusIndex as the entry is already focused
-                               this.focused = index
-                       }
+               toggleUnifiedSearch() {
+                       this.showUnifiedSearch = !this.showUnifiedSearch
                },
-
-               onClickFilter(filter) {
-                       this.query = `${this.query} ${filter}`
-                               .replace(/ {2}/g, ' ')
-                               .trim()
-                       this.onInput()
+               handleModalVisibilityChange(newVisibilityVal) {
+                       this.showUnifiedSearch = newVisibilityVal
                },
        },
 }
 </script>
 
 <style lang="scss" scoped>
-@use "sass:math";
-
-$margin: 10px;
-$input-height: 34px;
-$input-padding: 10px;
-
-.unified-search {
-       &__input-wrapper {
-               position: sticky;
-               // above search results
-               z-index: 2;
-               top: 0;
-               display: inline-flex;
-               flex-direction: column;
-               align-items: center;
-               width: 100%;
-               background-color: var(--color-main-background);
+.header-menu {
+       display: flex;
+       align-items: center;
+       justify-content: center;
 
-               label[for="unified-search__input"] {
-                       align-self: flex-start;
-                       font-weight: bold;
-                       font-size: 19px;
-                       margin-left: 13px;
-               }
-       }
-
-       &__form-input {
-               margin: 0 !important;
-               &:focus,
-               &:focus-visible,
-               &:active {
-                       border-color: 2px solid var(--color-main-text) !important;
-                       box-shadow: 0 0 0 2px var(--color-main-background) !important;
-               }
-       }
-
-       &__input-row {
+       .unified-search__button {
                display: flex;
-               width: 100%;
                align-items: center;
-       }
-
-       &__filters {
-               margin: $margin 0 $margin math.div($margin, 2);
-               padding-top: 5px;
-               ul {
-                       display: inline-flex;
-                       justify-content: space-between;
-               }
-       }
-
-       &__form {
-               position: relative;
-               width: 100%;
-               margin: $margin 0;
-
-               // Loading spinner
-               &::after {
-                       right: $input-padding;
-                       left: auto;
-               }
-
-               &-input,
-               &-reset {
-                       margin: math.div($input-padding, 2);
-               }
-
-               &-input {
-                       width: 100%;
-                       height: $input-height;
-                       padding: $input-padding;
-
-                       &,
-                       &[placeholder],
-                       &::placeholder {
-                               overflow: hidden;
-                               white-space: nowrap;
-                               text-overflow: ellipsis;
-                       }
-
-                       // Hide webkit clear search
-                       &::-webkit-search-decoration,
-                       &::-webkit-search-cancel-button,
-                       &::-webkit-search-results-button,
-                       &::-webkit-search-results-decoration {
-                               -webkit-appearance: none;
-                       }
-               }
-
-               &-reset, &-submit {
-                       position: absolute;
-                       top: 0;
-                       right: 4px;
-                       width: $input-height - $input-padding;
-                       height: $input-height - $input-padding;
-                       min-height: 30px;
-                       padding: 0;
-                       opacity: .5;
-                       border: none;
-                       background-color: transparent;
-                       margin-right: 0;
-
-                       &:hover,
-                       &:focus,
-                       &:active {
-                               opacity: 1;
-                       }
-               }
-
-               &-submit {
-                       right: 28px;
+               justify-content: center;
+               width: var(--header-height);
+               // height: var(--header-height);
+               margin: 0;
+               padding: 0;
+               cursor: pointer;
+               opacity: .85;
+               background-color: transparent;
+               border: none;
+               filter: none !important;
+               color: var(--color-primary-text) !important;
+
+               &:hover {
+                       background-color: transparent !important;
                }
        }
+}
 
-       &__results {
-               &-header {
-                       display: block;
-                       margin: $margin;
-                       margin-bottom: $margin - 4px;
-                       margin-left: 13px;
-                       color: var(--color-primary-element);
-                       font-size: 19px;
-                       font-weight: bold;
-               }
-               display: flex;
-               flex-direction: column;
-               gap: 4px;
-       }
-
-       .unified-search__result-more::v-deep {
-               color: var(--color-text-maxcontrast);
-       }
-
-       .empty-content {
-               margin: 10vh 0;
-
-               ::v-deep .empty-content__title {
-                       font-weight: normal;
-            font-size: var(--default-font-size);
-                       text-align: center;
-               }
+.unified-search-modal {
+       ::v-deep .modal-container {
+               height: 80%;
        }
 }
-
 </style>
diff --git a/core/src/views/UnifiedSearchModal.vue b/core/src/views/UnifiedSearchModal.vue
new file mode 100644 (file)
index 0000000..ca314fa
--- /dev/null
@@ -0,0 +1,611 @@
+<template>
+       <NcModal id="unified-search"
+               ref="unifiedSearchModal"
+               :name="t('core', 'Unified search')"
+               :show.sync="internalIsVisible"
+               :clear-view-delay="0"
+               :title="t('Unified search')"
+               @close="closeModal">
+               <CustomDateRangeModal :is-open="showDateRangeModal"
+                       class="unified-search__date-range"
+                       @set:custom-date-range="setCustomDateRange"
+                       @update:is-open="showDateRangeModal = $event" />
+               <!-- Unified search form -->
+               <div ref="unifiedSearch" class="unified-search-modal">
+                       <h1>{{ t('core', 'Unified search') }}</h1>
+                       <NcInputField ref="searchInput"
+                               :value.sync="searchQuery"
+                               type="text"
+                               :label="t('core', 'Search apps, files, tags, messages') + '...'"
+                               @update:value="debouncedFind" />
+                       <div class="unified-search-modal__filters">
+                               <NcActions :menu-name="t('core', 'Apps and Settings')" :open.sync="providerActionMenuIsOpen">
+                                       <template #icon>
+                                               <ListBox :size="20" />
+                                       </template>
+                                       <NcActionButton v-for="provider in providers" :key="provider.id" @click="addProviderFilter(provider)">
+                                               <template #icon>
+                                                       <img :src="provider.icon">
+                                               </template>
+                                               {{ t('core', provider.name) }}
+                                       </NcActionButton>
+                               </NcActions>
+                               <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen">
+                                       <template #icon>
+                                               <CalendarRangeIcon :size="20" />
+                                       </template>
+                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
+                                               {{ t('core', 'Today') }}
+                                       </NcActionButton>
+                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
+                                               {{ t('core', 'Last 7 days') }}
+                                       </NcActionButton>
+                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
+                                               {{ t('core', 'Last 30 days') }}
+                                       </NcActionButton>
+                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
+                                               {{ t('core', 'This year') }}
+                                       </NcActionButton>
+                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
+                                               {{ t('core', 'Last year') }}
+                                       </NcActionButton>
+                                       <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
+                                               {{ t('core', 'Custom date range') }}
+                                       </NcActionButton>
+                               </NcActions>
+                               <SearchableList :label-text="t('core', 'Search people')"
+                                       :search-list="userContacts"
+                                       :empty-content-text="t('core', 'Not found')"
+                                       @item-selected="applyPersonFilter">
+                                       <template #trigger>
+                                               <NcButton>
+                                                       <template #icon>
+                                                               <AccountGroup :size="20" />
+                                                       </template>
+                                                       {{ t('core', 'People') }}
+                                               </NcButton>
+                                       </template>
+                               </SearchableList>
+                       </div>
+                       <div class="unified-search-modal__filters-applied">
+                               <FilterChip v-for="filter in filters"
+                                       :key="filter.id"
+                                       :text="filter.name ?? filter.text"
+                                       :pretext="''"
+                                       @delete="removeFilter(filter)">
+                                       <template #icon>
+                                               <NcAvatar v-if="filter.type === 'person'"
+                                                       :user="filter.user"
+                                                       :size="24"
+                                                       :disable-menu="true"
+                                                       :show-user-status="false"
+                                                       :hide-favorite="false" />
+                                               <CalendarRangeIcon v-else-if="filter.type === 'date'" />
+                                               <img v-else :src="filter.icon" alt="">
+                                       </template>
+                               </FilterChip>
+                       </div>
+                       <div v-if="noContentInfo.show" class="unified-search-modal__no-content">
+                               <NcEmptyContent :name="noContentInfo.text">
+                                       <template #icon>
+                                               <component :is="noContentInfo.icon" />
+                                       </template>
+                               </NcEmptyContent>
+                       </div>
+                       <div v-for="providerResult in results" :key="providerResult.id" class="unified-search-modal__results">
+                               <div class="results">
+                                       <div class="result-title">
+                                               <span>{{ providerResult.provider }}</span>
+                                       </div>
+                                       <ul class="result-items">
+                                               <SearchResult v-for="(result, index) in providerResult.results" :key="index" v-bind="result" />
+                                       </ul>
+                                       <div class="result-footer">
+                                               <NcButton type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult.id)">
+                                                       {{ t('core', 'Load more results') }}
+                                                       <template #icon>
+                                                               <DotsHorizontalIcon :size="20" />
+                                                       </template>
+                                               </NcButton>
+                                               <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
+                                                       {{ t('core', 'Search in') }} {{ providerResult.provider }}
+                                                       <template #icon>
+                                                               <ArrowRight :size="20" />
+                                                       </template>
+                                               </NcButton>
+                                       </div>
+                               </div>
+                       </div>
+                       <div v-if="supportFiltering()" class="unified-search-modal__results">
+                               <NcButton @click="closeModal">
+                                       {{ t('core', 'Filter in current view') }}
+                                       <template #icon>
+                                               <FilterIcon :size="20" />
+                                       </template>
+                               </NcButton>
+                       </div>
+               </div>
+       </NcModal>
+</template>
+
+<script>
+import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
+import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
+import CustomDateRangeModal from '../components/UnifiedSearch/CustomDateRangeModal.vue'
+import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
+import FilterIcon from 'vue-material-design-icons/Filter.vue'
+import FilterChip from '../components/UnifiedSearch/SearchFilterChip.vue'
+import ListBox from 'vue-material-design-icons/ListBox.vue'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
+import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
+import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
+import MagnifyIcon from 'vue-material-design-icons/Magnify.vue'
+import SearchableList from '../components/UnifiedSearch/SearchableList.vue'
+import SearchResult from '../components/UnifiedSearch/SearchResult.vue'
+
+import debounce from 'debounce'
+import { emit } from '@nextcloud/event-bus'
+import { getProviders, search as unifiedSearch, getContacts } from '../services/UnifiedSearchService.js'
+
+export default {
+       name: 'UnifiedSearchModal',
+       components: {
+               ArrowRight,
+               AccountGroup,
+               CalendarRangeIcon,
+               CustomDateRangeModal,
+               DotsHorizontalIcon,
+               FilterIcon,
+               FilterChip,
+               ListBox,
+               NcActions,
+               NcActionButton,
+               NcAvatar,
+               NcButton,
+               NcEmptyContent,
+               NcModal,
+               NcInputField,
+               MagnifyIcon,
+               SearchableList,
+               SearchResult,
+       },
+       props: {
+               isVisible: {
+                       type: Boolean,
+                       required: true,
+               },
+       },
+       data() {
+               return {
+                       providers: [],
+                       providerActionMenuIsOpen: false,
+                       dateActionMenuIsOpen: false,
+                       providerResultLimit: 5,
+                       dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
+                       personFilter: { id: 'person', type: 'person', name: '' },
+                       dateFilterIsApplied: false,
+                       personFilterIsApplied: false,
+                       filteredProviders: [],
+                       searching: false,
+                       searchQuery: '',
+                       placesFilter: '',
+                       dateTimeFilter: null,
+                       filters: [],
+                       results: [],
+                       contacts: [],
+                       debouncedFind: debounce(this.find, 300),
+                       showDateRangeModal: false,
+                       internalIsVisible: false,
+               }
+       },
+
+       computed: {
+               userContacts: {
+                       get() {
+                               return this.contacts
+                       },
+               },
+               noContentInfo: {
+                       get() {
+                               const isEmptySearch = this.searchQuery.length === 0
+                               const hasNoResults = this.searchQuery.length > 0 && this.results.length === 0
+
+                               return {
+                                       show: isEmptySearch || hasNoResults,
+                                       text: this.searching && hasNoResults ? t('core', 'Searching â€¦') : (isEmptySearch ? t('core', 'Start typing in search') : t('core', 'No matching results')),
+                                       icon: MagnifyIcon,
+                               }
+                       },
+               },
+       },
+       watch: {
+               isVisible(value) {
+                       this.internalIsVisible = value
+               },
+               internalIsVisible(value) {
+                       this.$emit('update:isVisible', value)
+                       this.$nextTick(() => {
+                               if (value) {
+                                       this.focusInput()
+                               }
+                       })
+               },
+
+       },
+       mounted() {
+               getProviders().then((providers) => {
+                       this.providers = providers
+                       console.debug('Search providers', this.providers)
+               })
+               getContacts({ filter: '' }).then((contacts) => {
+                       this.contacts = this.mapContacts(contacts)
+                       console.debug('Contacts', this.contacts)
+               })
+       },
+       methods: {
+               find(query) {
+                       this.searching = true
+                       if (query.length === 0) {
+                               this.results = []
+                               this.searching = false
+                               return
+                       }
+                       // Event should probably be refactored at some point to used nextcloud:unified-search.search
+                       emit('nextcloud:unified-search.search', { query })
+                       const newResults = []
+                       const providersToSearch = this.filteredProviders.length > 0 ? this.filteredProviders : this.providers
+                       const searchProvider = (provider, filters) => {
+                               const params = {
+                                       type: provider.id,
+                                       query,
+                                       cursor: null,
+                               }
+
+                               if (filters.dateFilterIsApplied) {
+                                       if (provider.filters.since && provider.filters.until) {
+                                               params.since = this.dateFilter.startFrom
+                                               params.until = this.dateFilter.endAt
+                                       } else {
+                                               // Date filter is applied but provider does not support it, no need to search provider
+                                               return
+                                       }
+                               }
+
+                               if (filters.personFilterIsApplied) {
+                                       if (provider.filters.person) {
+                                               params.person = this.personFilter.user
+                                       } else {
+                                               // Person filter is applied but provider does not support it, no need to search provider
+                                               return
+                                       }
+                               }
+
+                               if (this.providerResultLimit > 5) {
+                                       params.limit = this.providerResultLimit
+                               }
+
+                               const request = unifiedSearch(params).request
+
+                               request().then((response) => {
+                                       newResults.push({
+                                               id: provider.id,
+                                               provider: provider.name,
+                                               inAppSearch: provider.inAppSearch,
+                                               results: response.data.ocs.data.entries,
+                                       })
+
+                                       console.debug('New results', newResults)
+                                       console.debug('Unified search results:', this.results)
+
+                                       this.updateResults(newResults)
+                                       this.searching = false
+                               })
+                       }
+                       providersToSearch.forEach(provider => {
+                               const dateFilterIsApplied = this.dateFilterIsApplied
+                               const personFilterIsApplied = this.personFilterIsApplied
+                               searchProvider(provider, { dateFilterIsApplied, personFilterIsApplied })
+                       })
+
+               },
+               updateResults(newResults) {
+                       let updatedResults = [...this.results]
+                       // If filters are applied, remove any previous results for providers that are not in current filters
+                       if (this.filters.length > 0) {
+                               updatedResults = updatedResults.filter(result => {
+                                       return this.filters.some(filter => filter.id === result.id)
+                               })
+                       }
+                       // Process the new results
+                       newResults.forEach(newResult => {
+                               const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
+                               if (existingResultIndex !== -1) {
+                                       if (newResult.results.length === 0) {
+                                               // If the new results data has no matches for and existing result, remove the existing result
+                                               updatedResults.splice(existingResultIndex, 1)
+                                       } else {
+                                               // If input triggered a change in existing results, update existing result
+                                               updatedResults.splice(existingResultIndex, 1, newResult)
+                                       }
+                               } else if (newResult.results.length > 0) {
+                                       // Push the new result to the array only if its results array is not empty
+                                       updatedResults.push(newResult)
+                               }
+                       })
+                       const sortedResults = updatedResults.slice(0)
+                       // Order results according to provider preference
+                       sortedResults.sort((a, b) => {
+                               const aProvider = this.providers.find(provider => provider.id === a.id)
+                               const bProvider = this.providers.find(provider => provider.id === b.id)
+                               const aOrder = aProvider ? aProvider.order : 0
+                               const bOrder = bProvider ? bProvider.order : 0
+                               return aOrder - bOrder
+                       })
+                       this.results = sortedResults
+               },
+               mapContacts(contacts) {
+                       return contacts.map(contact => {
+                               return {
+                                       // id: contact.id,
+                                       // name: '',
+                                       displayName: contact.fullName,
+                                       isNoUser: false,
+                                       subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
+                                       icon: '',
+                                       user: contact.id,
+                               }
+                       })
+               },
+               filterContacts(query) {
+                       getContacts({ filter: query }).then((contacts) => {
+                               this.contacts = this.mapContacts(contacts)
+                               console.debug(`Contacts filtered by ${query}`, this.contacts)
+                       })
+               },
+               applyPersonFilter(person) {
+                       this.personFilterIsApplied = true
+                       const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
+                       if (existingPersonFilter === -1) {
+                               this.personFilter.id = person.id
+                               this.personFilter.user = person.user
+                               this.personFilter.name = person.displayName
+                               this.filters.push(this.personFilter)
+                       } else {
+                               this.filters[existingPersonFilter].id = person.id
+                               this.filters[existingPersonFilter].user = person.user
+                               this.filters[existingPersonFilter].name = person.displayName
+                       }
+
+                       this.debouncedFind(this.searchQuery)
+                       console.debug('Person filter applied', person)
+               },
+               loadMoreResultsForProvider(providerId) {
+                       this.providerResultLimit += 5
+                       this.filters = this.filters.filter(filter => filter.type !== 'provider')
+                       const provider = this.providers.find(provider => provider.id === providerId)
+                       this.addProviderFilter(provider, true)
+               },
+               addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
+                       if (!providerFilter.id) return
+                       this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
+                       this.providerActionMenuIsOpen = false
+                       const existingFilter = this.filteredProviders.find(existing => existing.id === providerFilter.id)
+                       if (!existingFilter) {
+                               this.filteredProviders.push({ id: providerFilter.id, name: providerFilter.name, icon: providerFilter.icon, type: 'provider', filters: providerFilter.filters })
+                       }
+                       this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+                       console.debug('Search filters (newly added)', this.filters)
+                       this.debouncedFind(this.searchQuery)
+               },
+               removeFilter(filter) {
+                       if (filter.type === 'provider') {
+                               for (let i = 0; i < this.filteredProviders.length; i++) {
+                                       if (this.filteredProviders[i].id === filter.id) {
+                                               this.filteredProviders.splice(i, 1)
+                                               break
+                                       }
+                               }
+                               this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+                               console.debug('Search filters (recently removed)', this.filters)
+
+                       } else {
+                               for (let i = 0; i < this.filters.length; i++) {
+                                       // Remove date and person filter
+                                       if (this.filters[i].id === 'date' || this.filters[i].id === filter.id) {
+                                               this.dateFilterIsApplied = false
+                                               this.filters.splice(i, 1)
+                                               if (filter.type === 'person') {
+                                                       this.personFilterIsApplied = false
+                                               }
+                                               break
+                                       }
+                               }
+                       }
+                       this.debouncedFind(this.searchQuery)
+               },
+               syncProviderFilters(firstArray, secondArray) {
+                       // Create a copy of the first array to avoid modifying it directly.
+                       const synchronizedArray = firstArray.slice()
+                       // Remove items from the synchronizedArray that are not in the secondArray.
+                       synchronizedArray.forEach((item, index) => {
+                               const itemId = item.id
+                               if (item.type === 'provider') {
+                                       if (!secondArray.some(secondItem => secondItem.id === itemId)) {
+                                               synchronizedArray.splice(index, 1)
+                                       }
+                               }
+                       })
+                       // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
+                       secondArray.forEach(secondItem => {
+                               const itemId = secondItem.id
+                               if (secondItem.type === 'provider') {
+                                       if (!synchronizedArray.some(item => item.id === itemId)) {
+                                               synchronizedArray.push(secondItem)
+                                       }
+                               }
+                       })
+
+                       return synchronizedArray
+               },
+               updateDateFilter() {
+                       const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
+                       if (currFilterIndex !== -1) {
+                               this.filters[currFilterIndex] = this.dateFilter
+                       } else {
+                               this.filters.push(this.dateFilter)
+                       }
+                       this.dateFilterIsApplied = true
+                       this.debouncedFind(this.searchQuery)
+               },
+               applyQuickDateRange(range) {
+                       this.dateActionMenuIsOpen = false
+                       const today = new Date()
+                       let startDate
+                       let endDate
+
+                       switch (range) {
+                       case 'today':
+                               // For 'Today', both start and end are set to today
+                               startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
+                               endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
+                               this.dateFilter.text = t('core', 'Today')
+                               break
+                       case '7days':
+                               // For 'Last 7 days', start date is 7 days ago, end is today
+                               startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
+                               this.dateFilter.text = t('core', 'Last 7 days')
+                               break
+                       case '30days':
+                               // For 'Last 30 days', start date is 30 days ago, end is today
+                               startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
+                               this.dateFilter.text = t('core', 'Last 30 days')
+                               break
+                       case 'thisyear':
+                               // For 'This year', start date is the first day of the year, end is the last day of the year
+                               startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
+                               endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
+                               this.dateFilter.text = t('core', 'This year')
+                               break
+                       case 'lastyear':
+                               // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
+                               startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
+                               endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
+                               this.dateFilter.text = t('core', 'Last year')
+                               break
+                       case 'custom':
+                               this.showDateRangeModal = true
+                               return
+                       default:
+                               return
+                       }
+                       this.dateFilter.startFrom = startDate
+                       this.dateFilter.endAt = endDate
+                       this.updateDateFilter()
+
+               },
+               setCustomDateRange(event) {
+                       console.debug('Custom date range', event)
+                       this.dateFilter.startFrom = event.startFrom
+                       this.dateFilter.endAt = event.endAt
+                       this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
+                       this.updateDateFilter()
+               },
+               focusInput() {
+                       this.$refs.searchInput.$el.children[0].children[0].focus()
+               },
+               closeModal() {
+                       this.internalIsVisible = false
+                       this.searchQuery = ''
+               },
+               supportFiltering() {
+                       /* Hard coded apps for the moment this would be improved in coming updates. */
+                       const providerPaths = ['/settings/users', '/apps/files', '/apps/deck']
+                       const currentPath = window.location.pathname.replace('/index.php', '')
+                       const containsProvider = providerPaths.some(path => currentPath.includes(path))
+                       return containsProvider
+               },
+       },
+}
+</script>
+
+<style lang="scss" scoped>
+.unified-search-modal {
+       padding: 10px 20px 10px 20px;
+       height: 60%;
+
+       &__heading {
+               font-size: 16px;
+               font-weight: bolder;
+               line-height: 2em;
+               margin-bottom: 0;
+       }
+
+       &__filters {
+               display: flex;
+               padding-top: 4px;
+               justify-content: left;
+
+               >* {
+                       margin-right: 4px;
+
+               }
+
+       }
+
+       &__filters-applied {
+               padding-top: 4px;
+               display: flex;
+               flex-wrap: wrap;
+       }
+
+       &__no-content {
+               display: flex;
+               align-items: center;
+               height: 100%;
+       }
+
+       &__results {
+               padding: 10px;
+
+               .results {
+
+                       .result-title {
+                               span {
+                                       color: var(--color-primary-element);
+                                       font-weight: bolder;
+                                       font-size: 16px;
+                               }
+                       }
+
+                       .result-footer {
+                               justify-content: space-between;
+                               align-items: center;
+                               display: flex;
+                       }
+               }
+
+       }
+}
+
+div.v-popper__wrapper {
+       ul {
+               li {
+                       ::v-deep button.action-button {
+                               align-items: center !important;
+
+                               img {
+                                       width: 20px;
+                                       margin: 0 4px;
+                                       filter: var(--background-invert-if-bright);
+                               }
+
+                       }
+               }
+       }
+}
+</style>
index 9e04fed196f27a2111c9885d719ae4e652c0d8dd..9c23930f324913e6507dc45e0999af12e89d5209 100644 (file)
@@ -68,7 +68,6 @@ p($theme->getTitle());
                        </div>
 
                        <div class="header-right">
-                           <div id="global-search"></div>
                                <div id="unified-search"></div>
                                <div id="notifications"></div>
                                <div id="contactsmenu"></div>
index b8c8f5b8e676a9c53b4145a745af78e0bc7122de..13ac3c5ef482f5b942bb5c957887436348b4419c 100644 (file)
@@ -113,9 +113,9 @@ class TemplateLayout extends \OC_Template {
                                $this->initialState->provideInitialState('unified-search', 'limit-default', (int)$this->config->getAppValue('core', 'unified-search.limit-default', (string)SearchQuery::LIMIT_DEFAULT));
                                $this->initialState->provideInitialState('unified-search', 'min-search-length', (int)$this->config->getAppValue('core', 'unified-search.min-search-length', (string)1));
                                $this->initialState->provideInitialState('unified-search', 'live-search', $this->config->getAppValue('core', 'unified-search.live-search', 'yes') === 'yes');
-                               Util::addScript('core', 'unified-search', 'core');
+                               Util::addScript('core', 'legacy-unified-search', 'core');
                        } else {
-                               Util::addScript('core', 'global-search', 'core');
+                               Util::addScript('core', 'unified-search', 'core');
                        }
                        // Set body data-theme
                        $this->assign('enabledThemes', []);
index 677d0b17aaa3b8e19d9042f2fab8449dcbd5eab7..e0913a4483f1b061f3375b621a112926a1fdeeeb 100644 (file)
@@ -38,8 +38,8 @@ module.exports = {
                profile: path.join(__dirname, 'core/src', 'profile.js'),
                recommendedapps: path.join(__dirname, 'core/src', 'recommendedapps.js'),
                systemtags: path.resolve(__dirname, 'core/src', 'systemtags/merged-systemtags.js'),
-               'global-search': path.join(__dirname, 'core/src', 'global-search.js'),
                'unified-search': path.join(__dirname, 'core/src', 'unified-search.js'),
+               'legacy-unified-search': path.join(__dirname, 'core/src', 'legacy-unified-search.js'),
                'unsupported-browser': path.join(__dirname, 'core/src', 'unsupported-browser.js'),
                'unsupported-browser-redirect': path.join(__dirname, 'core/src', 'unsupported-browser-redirect.js'),
        },