diff options
Diffstat (limited to 'apps/files/src/services')
-rw-r--r-- | apps/files/src/services/Files.ts | 66 | ||||
-rw-r--r-- | apps/files/src/services/HotKeysService.spec.ts | 172 | ||||
-rw-r--r-- | apps/files/src/services/HotKeysService.ts | 82 | ||||
-rw-r--r-- | apps/files/src/services/Search.spec.ts | 61 | ||||
-rw-r--r-- | apps/files/src/services/Search.ts | 43 | ||||
-rw-r--r-- | apps/files/src/services/Templates.js | 5 | ||||
-rw-r--r-- | apps/files/src/services/WebDavSearch.ts | 83 |
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())) +} |