aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/services')
-rw-r--r--apps/files/src/services/Files.ts66
-rw-r--r--apps/files/src/services/HotKeysService.spec.ts172
-rw-r--r--apps/files/src/services/HotKeysService.ts82
-rw-r--r--apps/files/src/services/Search.spec.ts61
-rw-r--r--apps/files/src/services/Search.ts43
-rw-r--r--apps/files/src/services/Templates.js5
-rw-r--r--apps/files/src/services/WebDavSearch.ts83
7 files changed, 251 insertions, 261 deletions
diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts
index f02b48f64f3..080ce91e538 100644
--- a/apps/files/src/services/Files.ts
+++ b/apps/files/src/services/Files.ts
@@ -2,25 +2,55 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import type { ContentsWithRoot, File, Folder } from '@nextcloud/files'
+import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed } from 'webdav'
-import { davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files'
+import { defaultRootPath, getDefaultPropfind, resultToNode as davResultToNode } from '@nextcloud/files/dav'
import { CancelablePromise } from 'cancelable-promise'
import { join } from 'path'
import { client } from './WebdavClient.ts'
+import { searchNodes } from './WebDavSearch.ts'
+import { getPinia } from '../store/index.ts'
+import { useFilesStore } from '../store/files.ts'
+import { useSearchStore } from '../store/search.ts'
import logger from '../logger.ts'
-
/**
* Slim wrapper over `@nextcloud/files` `davResultToNode` to allow using the function with `Array.map`
* @param stat The result returned by the webdav library
*/
-export const resultToNode = (stat: FileStat): File | Folder => davResultToNode(stat)
+export const resultToNode = (stat: FileStat): Node => davResultToNode(stat)
-export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
- path = join(davRootPath, path)
+/**
+ * Get contents implementation for the files view.
+ * This also allows to fetch local search results when the user is currently filtering.
+ *
+ * @param path - The path to query
+ */
+export function getContents(path = '/'): CancelablePromise<ContentsWithRoot> {
const controller = new AbortController()
- const propfindPayload = davGetDefaultPropfind()
+ const searchStore = useSearchStore(getPinia())
+
+ if (searchStore.query.length >= 3) {
+ return new CancelablePromise((resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ getLocalSearch(path, searchStore.query, controller.signal)
+ .then(resolve)
+ .catch(reject)
+ })
+ } else {
+ return defaultGetContents(path)
+ }
+}
+
+/**
+ * Generic `getContents` implementation for the users files.
+ *
+ * @param path - The path to get the contents
+ */
+export function defaultGetContents(path: string): CancelablePromise<ContentsWithRoot> {
+ path = join(defaultRootPath, path)
+ const controller = new AbortController()
+ const propfindPayload = getDefaultPropfind()
return new CancelablePromise(async (resolve, reject, onCancel) => {
onCancel(() => controller.abort())
@@ -56,3 +86,25 @@ export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> =>
}
})
}
+
+/**
+ * Get the local search results for the current folder.
+ *
+ * @param path - The path
+ * @param query - The current search query
+ * @param signal - The aboort signal
+ */
+async function getLocalSearch(path: string, query: string, signal: AbortSignal): Promise<ContentsWithRoot> {
+ const filesStore = useFilesStore(getPinia())
+ let folder = filesStore.getDirectoryByPath('files', path)
+ if (!folder) {
+ const rootPath = join(defaultRootPath, path)
+ const stat = await client.stat(rootPath, { details: true }) as ResponseDataDetailed<FileStat>
+ folder = resultToNode(stat.data) as Folder
+ }
+ const contents = await searchNodes(query, { dir: path, signal })
+ return {
+ folder,
+ contents,
+ }
+}
diff --git a/apps/files/src/services/HotKeysService.spec.ts b/apps/files/src/services/HotKeysService.spec.ts
deleted file mode 100644
index c732c728ce5..00000000000
--- a/apps/files/src/services/HotKeysService.spec.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import { File, Permission, View } from '@nextcloud/files'
-import { describe, it, vi, expect, beforeEach, beforeAll, afterEach } from 'vitest'
-import { nextTick } from 'vue'
-import axios from '@nextcloud/axios'
-
-import { getPinia } from '../store/index.ts'
-import { useActiveStore } from '../store/active.ts'
-
-import { action as deleteAction } from '../actions/deleteAction.ts'
-import { action as favoriteAction } from '../actions/favoriteAction.ts'
-import { action as renameAction } from '../actions/renameAction.ts'
-import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { registerHotkeys } from './HotKeysService.ts'
-import { useUserConfigStore } from '../store/userconfig.ts'
-
-let file: File
-const view = {
- id: 'files',
- name: 'Files',
-} as View
-
-vi.mock('../actions/sidebarAction.ts', { spy: true })
-vi.mock('../actions/deleteAction.ts', { spy: true })
-vi.mock('../actions/favoriteAction.ts', { spy: true })
-vi.mock('../actions/renameAction.ts', { spy: true })
-
-describe('HotKeysService testing', () => {
- const activeStore = useActiveStore(getPinia())
-
- const goToRouteMock = vi.fn()
-
- let initialState: HTMLInputElement
-
- afterEach(() => {
- document.body.removeChild(initialState)
- })
-
- beforeAll(() => {
- registerHotkeys()
- })
-
- beforeEach(() => {
- // Make sure the router is reset before each test
- goToRouteMock.mockClear()
-
- // Make sure the file is reset before each test
- file = new File({
- id: 1,
- source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
- owner: 'admin',
- mime: 'text/plain',
- permissions: Permission.ALL,
- })
-
- // Setting the view first as it reset the active node
- activeStore.onChangedView(view)
- activeStore.setActiveNode(file)
-
- window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } }
- window.OCP = { Files: { Router: { goToRoute: goToRouteMock, params: {}, query: {} } } }
-
- initialState = document.createElement('input')
- initialState.setAttribute('type', 'hidden')
- initialState.setAttribute('id', 'initial-state-files_trashbin-config')
- initialState.setAttribute('value', btoa(JSON.stringify({
- allow_delete: true,
- })))
- document.body.appendChild(initialState)
- })
-
- it('Pressing d should open the sidebar once', () => {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD' }))
-
- // Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', metaKey: true }))
-
- expect(sidebarAction.enabled).toHaveReturnedWith(true)
- expect(sidebarAction.exec).toHaveBeenCalledOnce()
- })
-
- it('Pressing F2 should rename the file', () => {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2' }))
-
- // Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', metaKey: true }))
-
- expect(renameAction.enabled).toHaveReturnedWith(true)
- expect(renameAction.exec).toHaveBeenCalledOnce()
- })
-
- it('Pressing s should toggle favorite', () => {
- vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS' }))
-
- // Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', metaKey: true }))
-
- expect(favoriteAction.enabled).toHaveReturnedWith(true)
- expect(favoriteAction.exec).toHaveBeenCalledOnce()
- })
-
- it('Pressing Delete should delete the file', async () => {
- // @ts-expect-error mocking private field
- vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
-
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete' }))
-
- // Modifier keys should not trigger the action
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', ctrlKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', altKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', shiftKey: true }))
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', metaKey: true }))
-
- expect(deleteAction.enabled).toHaveReturnedWith(true)
- expect(deleteAction.exec).toHaveBeenCalledOnce()
- })
-
- it('Pressing alt+up should go to parent directory', () => {
- expect(goToRouteMock).toHaveBeenCalledTimes(0)
- window.OCP.Files.Router.query = { dir: '/foo/bar' }
-
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', code: 'ArrowUp', altKey: true }))
-
- expect(goToRouteMock).toHaveBeenCalledOnce()
- expect(goToRouteMock.mock.calls[0][2].dir).toBe('/foo')
- })
-
- it('Pressing v should toggle grid view', async () => {
- vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())
-
- const userConfigStore = useUserConfigStore(getPinia())
- userConfigStore.userConfig.grid_view = false
- expect(userConfigStore.userConfig.grid_view).toBe(false)
-
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV' }))
- await nextTick()
-
- expect(userConfigStore.userConfig.grid_view).toBe(true)
- })
-
- it.each([
- ['ctrlKey'],
- ['altKey'],
- // those meta keys are still triggering...
- // ['shiftKey'],
- // ['metaKey']
- ])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => {
- vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())
-
- const userConfigStore = useUserConfigStore(getPinia())
- userConfigStore.userConfig.grid_view = false
- expect(userConfigStore.userConfig.grid_view).toBe(false)
-
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true }))
- await nextTick()
-
- expect(userConfigStore.userConfig.grid_view).toBe(false)
- })
-})
diff --git a/apps/files/src/services/HotKeysService.ts b/apps/files/src/services/HotKeysService.ts
deleted file mode 100644
index 1ed369b061b..00000000000
--- a/apps/files/src/services/HotKeysService.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
-import { dirname } from 'path'
-
-import { action as deleteAction } from '../actions/deleteAction.ts'
-import { action as favoriteAction } from '../actions/favoriteAction.ts'
-import { action as renameAction } from '../actions/renameAction.ts'
-import { action as sidebarAction } from '../actions/sidebarAction.ts'
-import { executeAction } from '../utils/actionUtils.ts'
-import { useUserConfigStore } from '../store/userconfig.ts'
-import logger from '../logger.ts'
-
-/**
- * This register the hotkeys for the Files app.
- * As much as possible, we try to have all the hotkeys in one place.
- * Please make sure to add tests for the hotkeys after adding a new one.
- */
-export const registerHotkeys = function() {
- // d opens the sidebar
- useHotKey('d', () => executeAction(sidebarAction), {
- stop: true,
- prevent: true,
- })
-
- // F2 renames the file
- useHotKey('F2', () => executeAction(renameAction), {
- stop: true,
- prevent: true,
- })
-
- // s toggle favorite
- useHotKey('s', () => executeAction(favoriteAction), {
- stop: true,
- prevent: true,
- })
-
- // Delete deletes the file
- useHotKey('Delete', () => executeAction(deleteAction), {
- stop: true,
- prevent: true,
- })
-
- // alt+up go to parent directory
- useHotKey('ArrowUp', goToParentDir, {
- stop: true,
- prevent: true,
- alt: true,
- })
-
- // v toggle grid view
- useHotKey('v', toggleGridView, {
- stop: true,
- prevent: true,
- })
-
- logger.debug('Hotkeys registered')
-}
-
-const goToParentDir = function() {
- const params = window.OCP.Files.Router?.params || {}
- const query = window.OCP.Files.Router?.query || {}
-
- const currentDir = (query?.dir || '/') as string
- const parentDir = dirname(currentDir)
-
- logger.debug('Navigating to parent directory', { parentDir })
- window.OCP.Files.Router.goToRoute(
- null,
- { ...params },
- { ...query, dir: parentDir },
- )
-}
-
-const toggleGridView = function() {
- const userConfigStore = useUserConfigStore()
- const value = userConfigStore?.userConfig?.grid_view
- logger.debug('Toggling grid view', { old: value, new: !value })
- userConfigStore.update('grid_view', !value)
-}
diff --git a/apps/files/src/services/Search.spec.ts b/apps/files/src/services/Search.spec.ts
new file mode 100644
index 00000000000..c2840521a15
--- /dev/null
+++ b/apps/files/src/services/Search.spec.ts
@@ -0,0 +1,61 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { getContents } from './Search.ts'
+import { Folder, Permission } from '@nextcloud/files'
+
+const searchNodes = vi.hoisted(() => vi.fn())
+vi.mock('./WebDavSearch.ts', () => ({ searchNodes }))
+vi.mock('@nextcloud/auth')
+
+describe('Search service', () => {
+ const fakeFolder = new Folder({ owner: 'owner', source: 'https://cloud.example.com/remote.php/dav/files/owner/folder', root: '/files/owner' })
+
+ beforeAll(() => {
+ window.OCP ??= {}
+ window.OCP.Files ??= {}
+ window.OCP.Files.Router ??= { params: {}, query: {} }
+ vi.spyOn(window.OCP.Files.Router, 'params', 'get').mockReturnValue({ view: 'files' })
+ })
+
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ setActivePinia(createPinia())
+ })
+
+ it('rejects on error', async () => {
+ searchNodes.mockImplementationOnce(() => { throw new Error('expected error') })
+ expect(getContents).rejects.toThrow('expected error')
+ })
+
+ it('returns the search results and a fake root', async () => {
+ searchNodes.mockImplementationOnce(() => [fakeFolder])
+ const { contents, folder } = await getContents()
+
+ expect(searchNodes).toHaveBeenCalledOnce()
+ expect(contents).toHaveLength(1)
+ expect(contents).toEqual([fakeFolder])
+ // read only root
+ expect(folder.permissions).toBe(Permission.READ)
+ })
+
+ it('can be cancelled', async () => {
+ const { promise, resolve } = Promise.withResolvers<Event>()
+ searchNodes.mockImplementationOnce(async (_, { signal }: { signal: AbortSignal}) => {
+ signal.addEventListener('abort', resolve)
+ await promise
+ return []
+ })
+
+ const content = getContents()
+ content.cancel()
+
+ // its cancelled thus the promise returns the event
+ const event = await promise
+ expect(event.type).toBe('abort')
+ })
+})
diff --git a/apps/files/src/services/Search.ts b/apps/files/src/services/Search.ts
new file mode 100644
index 00000000000..f1d7c30a94e
--- /dev/null
+++ b/apps/files/src/services/Search.ts
@@ -0,0 +1,43 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { ContentsWithRoot } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, Permission } from '@nextcloud/files'
+import { defaultRemoteURL } from '@nextcloud/files/dav'
+import { CancelablePromise } from 'cancelable-promise'
+import { searchNodes } from './WebDavSearch.ts'
+import logger from '../logger.ts'
+import { useSearchStore } from '../store/search.ts'
+import { getPinia } from '../store/index.ts'
+
+/**
+ * Get the contents for a search view
+ */
+export function getContents(): CancelablePromise<ContentsWithRoot> {
+ const controller = new AbortController()
+
+ const searchStore = useSearchStore(getPinia())
+
+ return new CancelablePromise<ContentsWithRoot>(async (resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ try {
+ const contents = await searchNodes(searchStore.query, { signal: controller.signal })
+ resolve({
+ contents,
+ folder: new Folder({
+ id: 0,
+ source: `${defaultRemoteURL}#search`,
+ owner: getCurrentUser()!.uid,
+ permissions: Permission.READ,
+ }),
+ })
+ } catch (error) {
+ logger.error('Failed to fetch search results', { error })
+ reject(error)
+ }
+ })
+}
diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js
index 3a0a0fdb809..d7f25846ceb 100644
--- a/apps/files/src/services/Templates.js
+++ b/apps/files/src/services/Templates.js
@@ -11,6 +11,11 @@ export const getTemplates = async function() {
return response.data.ocs.data
}
+export const getTemplateFields = async function(fileId) {
+ const response = await axios.get(generateOcsUrl(`apps/files/api/v1/templates/fields/${fileId}`))
+ return response.data.ocs.data
+}
+
/**
* Create a new file from a specified template
*
diff --git a/apps/files/src/services/WebDavSearch.ts b/apps/files/src/services/WebDavSearch.ts
new file mode 100644
index 00000000000..feb7f30b357
--- /dev/null
+++ b/apps/files/src/services/WebDavSearch.ts
@@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { ResponseDataDetailed, SearchResult } from 'webdav'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { defaultRootPath, getDavNameSpaces, getDavProperties, resultToNode } from '@nextcloud/files/dav'
+import { getBaseUrl } from '@nextcloud/router'
+import { client } from './WebdavClient.ts'
+import logger from '../logger.ts'
+
+export interface SearchNodesOptions {
+ dir?: string,
+ signal?: AbortSignal
+}
+
+/**
+ * Search for nodes matching the given query.
+ *
+ * @param query - Search query
+ * @param options - Options
+ * @param options.dir - The base directory to scope the search to
+ * @param options.signal - Abort signal for the request
+ */
+export async function searchNodes(query: string, { dir, signal }: SearchNodesOptions): Promise<INode[]> {
+ const user = getCurrentUser()
+ if (!user) {
+ // the search plugin only works for user roots
+ return []
+ }
+
+ query = query.trim()
+ if (query.length < 3) {
+ // the search plugin only works with queries of at least 3 characters
+ return []
+ }
+
+ if (dir && !dir.startsWith('/')) {
+ dir = `/${dir}`
+ }
+
+ logger.debug('Searching for nodes', { query, dir })
+ const { data } = await client.search('/', {
+ details: true,
+ signal,
+ data: `
+<d:searchrequest ${getDavNameSpaces()}>
+ <d:basicsearch>
+ <d:select>
+ <d:prop>
+ ${getDavProperties()}
+ </d:prop>
+ </d:select>
+ <d:from>
+ <d:scope>
+ <d:href>/files/${user.uid}${dir || ''}</d:href>
+ <d:depth>infinity</d:depth>
+ </d:scope>
+ </d:from>
+ <d:where>
+ <d:like>
+ <d:prop>
+ <d:displayname/>
+ </d:prop>
+ <d:literal>%${query.replace('%', '')}%</d:literal>
+ </d:like>
+ </d:where>
+ <d:orderby/>
+ </d:basicsearch>
+</d:searchrequest>`,
+ }) as ResponseDataDetailed<SearchResult>
+
+ // check if the request was aborted
+ if (signal?.aborted) {
+ return []
+ }
+
+ // otherwise return the result mapped to Nextcloud nodes
+ return data.results.map((result) => resultToNode(result, defaultRootPath, getBaseUrl()))
+}