diff options
Diffstat (limited to 'apps/files/src/services')
-rw-r--r-- | apps/files/src/services/DropService.ts | 5 | ||||
-rw-r--r-- | apps/files/src/services/DropServiceUtils.spec.ts | 17 | ||||
-rw-r--r-- | apps/files/src/services/DropServiceUtils.ts | 2 | ||||
-rw-r--r-- | apps/files/src/services/FileInfo.js | 29 | ||||
-rw-r--r-- | apps/files/src/services/FileInfo.ts | 36 | ||||
-rw-r--r-- | apps/files/src/services/Files.ts | 71 | ||||
-rw-r--r-- | apps/files/src/services/FolderTree.ts | 95 | ||||
-rw-r--r-- | apps/files/src/services/LivePhotos.ts | 3 | ||||
-rw-r--r-- | apps/files/src/services/PreviewService.ts | 15 | ||||
-rw-r--r-- | apps/files/src/services/Recent.ts | 18 | ||||
-rw-r--r-- | apps/files/src/services/RouterService.ts | 35 | ||||
-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/ServiceWorker.js | 14 | ||||
-rw-r--r-- | apps/files/src/services/SortingService.spec.ts | 100 | ||||
-rw-r--r-- | apps/files/src/services/SortingService.ts | 59 | ||||
-rw-r--r-- | apps/files/src/services/Templates.js | 9 | ||||
-rw-r--r-- | apps/files/src/services/WebDavSearch.ts | 83 | ||||
-rw-r--r-- | apps/files/src/services/WebdavClient.ts | 16 |
19 files changed, 467 insertions, 244 deletions
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts index b947c612949..1013baeda6c 100644 --- a/apps/files/src/services/DropService.ts +++ b/apps/files/src/services/DropService.ts @@ -17,7 +17,7 @@ import Vue from 'vue' import { Directory, traverseTree, resolveConflict, createDirectoryIfNotExists } from './DropServiceUtils' import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction' import { MoveCopyAction } from '../actions/moveOrCopyActionUtils' -import logger from '../logger.js' +import logger from '../logger.ts' /** * This function converts a list of DataTransferItems to a file tree. @@ -178,8 +178,7 @@ export const onDropInternalFiles = async (nodes: Node[], destination: Folder, co for (const node of nodes) { Vue.set(node, 'status', NodeStatus.LOADING) - // TODO: resolve potential conflicts prior and force overwrite - queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)) + queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true)) } // Wait for all promises to settle diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts index 9f947531198..5f4370c7894 100644 --- a/apps/files/src/services/DropServiceUtils.spec.ts +++ b/apps/files/src/services/DropServiceUtils.spec.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { describe, it, expect } from '@jest/globals' +import { beforeAll, describe, expect, it, vi } from 'vitest' import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils' import { join } from 'node:path' @@ -88,20 +88,17 @@ describe('Filesystem API traverseTree', () => { describe('DropService dataTransferToFileTree', () => { beforeAll(() => { + // @ts-expect-error jsdom doesn't have DataTransferItem + delete window.DataTransferItem // DataTransferItem doesn't exists in jsdom, let's mock // a dumb one so we can check the instanceof // @ts-expect-error jsdom doesn't have DataTransferItem window.DataTransferItem = DataTransferItemMock }) - afterAll(() => { - // @ts-expect-error jsdom doesn't have DataTransferItem - delete window.DataTransferItem - }) - it('Should return a RootDirectory with Filesystem API', async () => { - jest.spyOn(logger, 'error').mockImplementation(() => jest.fn()) - jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn()) + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn()) const dataTransferItems = buildDataTransferItemArray('root', dataTree) const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[]) @@ -121,8 +118,8 @@ describe('DropService dataTransferToFileTree', () => { }) it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => { - jest.spyOn(logger, 'error').mockImplementation(() => jest.fn()) - jest.spyOn(logger, 'warn').mockImplementation(() => jest.fn()) + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn()) const dataTransferItems = buildDataTransferItemArray('root', dataTree, false) diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts index 27478dd956a..f10a09cfe27 100644 --- a/apps/files/src/services/DropServiceUtils.ts +++ b/apps/files/src/services/DropServiceUtils.ts @@ -10,7 +10,7 @@ import { openConflictPicker } from '@nextcloud/upload' import { showError, showInfo } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' -import logger from '../logger.js' +import logger from '../logger.ts' /** * This represents a Directory in the file tree diff --git a/apps/files/src/services/FileInfo.js b/apps/files/src/services/FileInfo.js deleted file mode 100644 index 4e08cdb234d..00000000000 --- a/apps/files/src/services/FileInfo.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import axios from '@nextcloud/axios' -import { davGetDefaultPropfind } from '@nextcloud/files' - -/** - * @param {any} url - - */ -export default async function(url) { - const response = await axios({ - method: 'PROPFIND', - url, - data: davGetDefaultPropfind(), - }) - - // TODO: create new parser or use cdav-lib when available - const file = OC.Files.getClient()._client.parseMultiStatus(response.data) - // TODO: create new parser or use cdav-lib when available - const fileInfo = OC.Files.getClient()._parseFileInfo(file[0]) - - // TODO remove when no more legacy backbone is used - fileInfo.get = (key) => fileInfo[key] - fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory' - - return fileInfo -} diff --git a/apps/files/src/services/FileInfo.ts b/apps/files/src/services/FileInfo.ts new file mode 100644 index 00000000000..318236f1677 --- /dev/null +++ b/apps/files/src/services/FileInfo.ts @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable jsdoc/require-jsdoc */ + +import type { Node } from '@nextcloud/files' + +export default function(node: Node) { + const fileInfo = new OC.Files.FileInfo({ + id: node.fileid, + path: node.dirname, + name: node.basename, + mtime: node.mtime?.getTime(), + etag: node.attributes.etag, + size: node.size, + hasPreview: node.attributes.hasPreview, + isEncrypted: node.attributes.isEncrypted === 1, + isFavourited: node.attributes.favorite === 1, + mimetype: node.mime, + permissions: node.permissions, + mountType: node.attributes['mount-type'], + sharePermissions: node.attributes['share-permissions'], + shareAttributes: JSON.parse(node.attributes['share-attributes'] || '[]'), + type: node.type === 'file' ? 'file' : 'dir', + attributes: node.attributes, + }) + + // TODO remove when no more legacy backbone is used + fileInfo.get = (key) => fileInfo[key] + fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory' + fileInfo.canEdit = () => Boolean(fileInfo.permissions & OC.PERMISSION_UPDATE) + + return fileInfo +} diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts index dc83f16187b..080ce91e538 100644 --- a/apps/files/src/services/Files.ts +++ b/apps/files/src/services/Files.ts @@ -2,28 +2,59 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ContentsWithRoot } from '@nextcloud/files' +import type { ContentsWithRoot, File, Folder, Node } from '@nextcloud/files' import type { FileStat, ResponseDataDetailed } from 'webdav' +import { defaultRootPath, getDefaultPropfind, resultToNode as davResultToNode } from '@nextcloud/files/dav' import { CancelablePromise } from 'cancelable-promise' -import { File, Folder, davGetDefaultPropfind, davResultToNode, davRootPath } from '@nextcloud/files' +import { join } from 'path' import { client } from './WebdavClient.ts' -import logger from '../logger.js' - +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 node The node returned by the webdav library + * @param stat The result returned by the webdav library */ -export const resultToNode = (node: FileStat): File | Folder => davResultToNode(node) +export const resultToNode = (stat: FileStat): Node => davResultToNode(stat) -export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => { +/** + * 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()) - path = `${davRootPath}${path}` + 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()) + try { const contentsResponse = await client.getDirectoryContents(path, { details: true, @@ -55,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/FolderTree.ts b/apps/files/src/services/FolderTree.ts new file mode 100644 index 00000000000..82f0fb392e5 --- /dev/null +++ b/apps/files/src/services/FolderTree.ts @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ContentsWithRoot } from '@nextcloud/files' + +import { CancelablePromise } from 'cancelable-promise' +import { davRemoteURL } from '@nextcloud/files' +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { dirname, encodePath, joinPaths } from '@nextcloud/paths' +import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n' + +import { getContents as getFiles } from './Files.ts' + +// eslint-disable-next-line no-use-before-define +type Tree = TreeNodeData[] + +interface TreeNodeData { + id: number, + basename: string, + displayName?: string, + children: Tree, +} + +export interface TreeNode { + source: string, + encodedSource: string, + path: string, + fileid: number, + basename: string, + displayName?: string, +} + +export const folderTreeId = 'folders' + +export const sourceRoot = `${davRemoteURL}/files/${getCurrentUser()?.uid}` + +const collator = Intl.Collator( + [getLanguage(), getCanonicalLocale()], + { + numeric: true, + usage: 'sort', + }, +) + +const compareNodes = (a: TreeNodeData, b: TreeNodeData) => collator.compare(a.displayName ?? a.basename, b.displayName ?? b.basename) + +const getTreeNodes = (tree: Tree, currentPath: string = '/', nodes: TreeNode[] = []): TreeNode[] => { + const sortedTree = tree.toSorted(compareNodes) + for (const { id, basename, displayName, children } of sortedTree) { + const path = joinPaths(currentPath, basename) + const source = `${sourceRoot}${path}` + const node: TreeNode = { + source, + encodedSource: encodeSource(source), + path, + fileid: id, + basename, + } + if (displayName) { + node.displayName = displayName + } + nodes.push(node) + if (children.length > 0) { + getTreeNodes(children, path, nodes) + } + } + return nodes +} + +export const getFolderTreeNodes = async (path: string = '/', depth: number = 1): Promise<TreeNode[]> => { + const { data: tree } = await axios.get<Tree>(generateOcsUrl('/apps/files/api/v1/folder-tree'), { + params: new URLSearchParams({ path, depth: String(depth) }), + }) + const nodes = getTreeNodes(tree, path) + return nodes +} + +export const getContents = (path: string): CancelablePromise<ContentsWithRoot> => getFiles(path) + +export const encodeSource = (source: string): string => { + const { origin } = new URL(source) + return origin + encodePath(source.slice(origin.length)) +} + +export const getSourceParent = (source: string): string => { + const parent = dirname(source) + if (parent === sourceRoot) { + return folderTreeId + } + return encodeSource(parent) +} diff --git a/apps/files/src/services/LivePhotos.ts b/apps/files/src/services/LivePhotos.ts index aee89ac6c3d..10be42444e2 100644 --- a/apps/files/src/services/LivePhotos.ts +++ b/apps/files/src/services/LivePhotos.ts @@ -4,6 +4,9 @@ */ import { Node, registerDavProperty } from '@nextcloud/files' +/** + * + */ export function initLivePhotos(): void { registerDavProperty('nc:metadata-files-live-photo', { nc: 'http://nextcloud.org/ns' }) } diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts index 44864b18c01..6dbb67f30b6 100644 --- a/apps/files/src/services/PreviewService.ts +++ b/apps/files/src/services/PreviewService.ts @@ -8,17 +8,14 @@ const SWCacheName = 'previews' /** * Check if the preview is already cached by the service worker + * @param previewUrl URL to check */ -export const isCachedPreview = function(previewUrl: string): Promise<boolean> { +export async function isCachedPreview(previewUrl: string): Promise<boolean> { if (!window?.caches?.open) { - return Promise.resolve(false) + return false } - return window?.caches?.open(SWCacheName) - .then(function(cache) { - return cache.match(previewUrl) - .then(function(response) { - return !!response - }) - }) + const cache = await window.caches.open(SWCacheName) + const response = await cache.match(previewUrl) + return response !== undefined } diff --git a/apps/files/src/services/Recent.ts b/apps/files/src/services/Recent.ts index c8cde136069..d0ca285b05c 100644 --- a/apps/files/src/services/Recent.ts +++ b/apps/files/src/services/Recent.ts @@ -3,19 +3,27 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { ContentsWithRoot, Node } from '@nextcloud/files' -import type { ResponseDataDetailed, SearchResult } from 'webdav' +import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav' import { getCurrentUser } from '@nextcloud/auth' -import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL } from '@nextcloud/files' +import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL, davResultToNode } from '@nextcloud/files' import { CancelablePromise } from 'cancelable-promise' import { useUserConfigStore } from '../store/userconfig.ts' -import { pinia } from '../store/index.ts' +import { getPinia } from '../store/index.ts' import { client } from './WebdavClient.ts' -import { resultToNode } from './Files.ts' +import { getBaseUrl } from '@nextcloud/router' const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 14)) /** + * Helper to map a WebDAV result to a Nextcloud node + * The search endpoint already includes the dav remote URL so we must not include it in the source + * + * @param stat the WebDAV result + */ +const resultToNode = (stat: FileStat) => davResultToNode(stat, davRootPath, getBaseUrl()) + +/** * Get recently changed nodes * * This takes the users preference about hidden files into account. @@ -24,7 +32,7 @@ const lastTwoWeeksTimestamp = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 1 * @param path Path to search for recent changes */ export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => { - const store = useUserConfigStore(pinia) + const store = useUserConfigStore(getPinia()) /** * Filter function that returns only the visible nodes - or hidden if explicitly configured diff --git a/apps/files/src/services/RouterService.ts b/apps/files/src/services/RouterService.ts index 84516465495..4e2999b1d29 100644 --- a/apps/files/src/services/RouterService.ts +++ b/apps/files/src/services/RouterService.ts @@ -2,28 +2,37 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Route } from 'vue-router' +import type { Route, Location } from 'vue-router' import type VueRouter from 'vue-router' -import type { Dictionary, Location } from 'vue-router/types/router' export default class RouterService { - private _router: VueRouter + // typescript compiles this to `#router` to make it private even in JS, + // but in TS it needs to be called without the visibility specifier + private router: VueRouter constructor(router: VueRouter) { - this._router = router + this.router = router } get name(): string | null | undefined { - return this._router.currentRoute.name + return this.router.currentRoute.name } - get query(): Dictionary<string | (string | null)[] | null | undefined> { - return this._router.currentRoute.query || {} + get query(): Record<string, string | (string | null)[] | null | undefined> { + return this.router.currentRoute.query || {} } - get params(): Dictionary<string> { - return this._router.currentRoute.params || {} + get params(): Record<string, string> { + return this.router.currentRoute.params || {} + } + + /** + * This is a protected getter only for internal use + * @private + */ + get _router() { + return this.router } /** @@ -34,7 +43,7 @@ export default class RouterService { * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location */ goTo(path: string, replace = false): Promise<Route> { - return this._router.push({ + return this.router.push({ path, replace, }) @@ -51,11 +60,11 @@ export default class RouterService { */ goToRoute( name?: string, - params?: Dictionary<string>, - query?: Dictionary<string | (string | null)[] | null | undefined>, + params?: Record<string, string>, + query?: Record<string, string | (string | null)[] | null | undefined>, replace?: boolean, ): Promise<Route> { - return this._router.push({ + return this.router.push({ name, query, params, 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/ServiceWorker.js b/apps/files/src/services/ServiceWorker.js index 477354d1c36..cc13db44009 100644 --- a/apps/files/src/services/ServiceWorker.js +++ b/apps/files/src/services/ServiceWorker.js @@ -2,8 +2,8 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { generateUrl } from '@nextcloud/router' -import logger from '../logger.js' +import { generateUrl, getRootUrl } from '@nextcloud/router' +import logger from '../logger.ts' export default () => { if ('serviceWorker' in navigator) { @@ -11,7 +11,15 @@ export default () => { window.addEventListener('load', async () => { try { const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true }) - const registration = await navigator.serviceWorker.register(url, { scope: '/' }) + let scope = getRootUrl() + // If the instance is not in a subfolder an empty string will be returned. + // The service worker registration will use the current path if it receives an empty string, + // which will result in a service worker registration for every single path the user visits. + if (scope === '') { + scope = '/' + } + + const registration = await navigator.serviceWorker.register(url, { scope }) logger.debug('SW registered: ', { registration }) } catch (error) { logger.error('SW registration failed: ', { error }) diff --git a/apps/files/src/services/SortingService.spec.ts b/apps/files/src/services/SortingService.spec.ts deleted file mode 100644 index 5d20c43ed0a..00000000000 --- a/apps/files/src/services/SortingService.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { describe, expect } from '@jest/globals' -import { orderBy } from './SortingService' - -describe('SortingService', () => { - test('By default the identify and ascending order is used', () => { - const array = ['a', 'z', 'b'] - expect(orderBy(array)).toEqual(['a', 'b', 'z']) - }) - - test('Use identifiy but descending', () => { - const array = ['a', 'z', 'b'] - expect(orderBy(array, undefined, ['desc'])).toEqual(['z', 'b', 'a']) - }) - - test('Can set identifier function', () => { - const array = [ - { text: 'a', order: 2 }, - { text: 'z', order: 1 }, - { text: 'b', order: 3 }, - ] as const - expect(orderBy(array, [(v) => v.order]).map((v) => v.text)).toEqual(['z', 'a', 'b']) - }) - - test('Can set multiple identifier functions', () => { - const array = [ - { text: 'a', order: 2, secondOrder: 2 }, - { text: 'z', order: 1, secondOrder: 3 }, - { text: 'b', order: 2, secondOrder: 1 }, - ] as const - expect(orderBy(array, [(v) => v.order, (v) => v.secondOrder]).map((v) => v.text)).toEqual(['z', 'b', 'a']) - }) - - test('Can set order partially', () => { - const array = [ - { text: 'a', order: 2, secondOrder: 2 }, - { text: 'z', order: 1, secondOrder: 3 }, - { text: 'b', order: 2, secondOrder: 1 }, - ] as const - - expect( - orderBy( - array, - [(v) => v.order, (v) => v.secondOrder], - ['desc'], - ).map((v) => v.text), - ).toEqual(['b', 'a', 'z']) - }) - - test('Can set order array', () => { - const array = [ - { text: 'a', order: 2, secondOrder: 2 }, - { text: 'z', order: 1, secondOrder: 3 }, - { text: 'b', order: 2, secondOrder: 1 }, - ] as const - - expect( - orderBy( - array, - [(v) => v.order, (v) => v.secondOrder], - ['desc', 'desc'], - ).map((v) => v.text), - ).toEqual(['a', 'b', 'z']) - }) - - test('Numbers are handled correctly', () => { - const array = [ - { text: '2.3' }, - { text: '2.10' }, - { text: '2.0' }, - { text: '2.2' }, - ] as const - - expect( - orderBy( - array, - [(v) => v.text], - ).map((v) => v.text), - ).toEqual(['2.0', '2.2', '2.3', '2.10']) - }) - - test('Numbers with suffixes are handled correctly', () => { - const array = [ - { text: '2024-01-05' }, - { text: '2024-05-01' }, - { text: '2024-01-10' }, - { text: '2024-01-05 Foo' }, - ] as const - - expect( - orderBy( - array, - [(v) => v.text], - ).map((v) => v.text), - ).toEqual(['2024-01-05', '2024-01-05 Foo', '2024-01-10', '2024-05-01']) - }) -}) diff --git a/apps/files/src/services/SortingService.ts b/apps/files/src/services/SortingService.ts deleted file mode 100644 index 392f35efc9f..00000000000 --- a/apps/files/src/services/SortingService.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n' - -type IdentifierFn<T> = (v: T) => unknown -type SortingOrder = 'asc'|'desc' - -/** - * Helper to create string representation - * @param value Value to stringify - */ -function stringify(value: unknown) { - // The default representation of Date is not sortable because of the weekday names in front of it - if (value instanceof Date) { - return value.toISOString() - } - return String(value) -} - -/** - * Natural order a collection - * You can define identifiers as callback functions, that get the element and return the value to sort. - * - * @param collection The collection to order - * @param identifiers An array of identifiers to use, by default the identity of the element is used - * @param orders Array of orders, by default all identifiers are sorted ascening - */ -export function orderBy<T>(collection: readonly T[], identifiers?: IdentifierFn<T>[], orders?: SortingOrder[]): T[] { - // If not identifiers are set we use the identity of the value - identifiers = identifiers ?? [(value) => value] - // By default sort the collection ascending - orders = orders ?? [] - const sorting = identifiers.map((_, index) => (orders[index] ?? 'asc') === 'asc' ? 1 : -1) - - const collator = Intl.Collator( - [getLanguage(), getCanonicalLocale()], - { - // handle 10 as ten and not as one-zero - numeric: true, - usage: 'sort', - }, - ) - - return [...collection].sort((a, b) => { - for (const [index, identifier] of identifiers.entries()) { - // Get the local compare of stringified value a and b - const value = collator.compare(stringify(identifier(a)), stringify(identifier(b))) - // If they do not match return the order - if (value !== 0) { - return value * sorting[index] - } - // If they match we need to continue with the next identifier - } - // If all are equal we need to return equality - return 0 - }) -} diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js index 113e9d1488b..d7f25846ceb 100644 --- a/apps/files/src/services/Templates.js +++ b/apps/files/src/services/Templates.js @@ -11,18 +11,25 @@ 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 * * @param {string} filePath The new file destination path * @param {string} templatePath The template source path * @param {string} templateType The template type e.g 'user' + * @param {object} templateFields The template fields to fill in (if any) */ -export const createFromTemplate = async function(filePath, templatePath, templateType) { +export const createFromTemplate = async function(filePath, templatePath, templateType, templateFields) { const response = await axios.post(generateOcsUrl('apps/files/api/v1/templates/create'), { filePath, templatePath, templateType, + templateFields, }) return response.data.ocs.data } 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())) +} diff --git a/apps/files/src/services/WebdavClient.ts b/apps/files/src/services/WebdavClient.ts index 5563508e2c7..2b92deba9b4 100644 --- a/apps/files/src/services/WebdavClient.ts +++ b/apps/files/src/services/WebdavClient.ts @@ -2,6 +2,18 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { davGetClient } from '@nextcloud/files' +import type { FileStat, ResponseDataDetailed } from 'webdav' +import type { Node } from '@nextcloud/files' -export const client = davGetClient() +import { getClient, getDefaultPropfind, getRootPath, resultToNode } from '@nextcloud/files/dav' + +export const client = getClient() + +export const fetchNode = async (path: string): Promise<Node> => { + const propfindPayload = getDefaultPropfind() + const result = await client.stat(`${getRootPath()}${path}`, { + details: true, + data: propfindPayload, + }) as ResponseDataDetailed<FileStat> + return resultToNode(result.data) +} |