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/DropService.ts5
-rw-r--r--apps/files/src/services/DropServiceUtils.spec.ts17
-rw-r--r--apps/files/src/services/DropServiceUtils.ts2
-rw-r--r--apps/files/src/services/FileInfo.js29
-rw-r--r--apps/files/src/services/FileInfo.ts36
-rw-r--r--apps/files/src/services/Files.ts71
-rw-r--r--apps/files/src/services/FolderTree.ts95
-rw-r--r--apps/files/src/services/LivePhotos.ts3
-rw-r--r--apps/files/src/services/PreviewService.ts15
-rw-r--r--apps/files/src/services/Recent.ts18
-rw-r--r--apps/files/src/services/RouterService.ts35
-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/ServiceWorker.js14
-rw-r--r--apps/files/src/services/SortingService.spec.ts100
-rw-r--r--apps/files/src/services/SortingService.ts59
-rw-r--r--apps/files/src/services/Templates.js9
-rw-r--r--apps/files/src/services/WebDavSearch.ts83
-rw-r--r--apps/files/src/services/WebdavClient.ts16
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)
+}