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.ts198
-rw-r--r--apps/files/src/services/DropServiceUtils.spec.ts143
-rw-r--r--apps/files/src/services/DropServiceUtils.ts178
-rw-r--r--apps/files/src/services/Favorites.ts40
-rw-r--r--apps/files/src/services/FileInfo.js71
-rw-r--r--apps/files/src/services/FileInfo.ts36
-rw-r--r--apps/files/src/services/Files.ts110
-rw-r--r--apps/files/src/services/FolderTree.ts95
-rw-r--r--apps/files/src/services/LivePhotos.ts19
-rw-r--r--apps/files/src/services/Navigation.ts217
-rw-r--r--apps/files/src/services/PersonalFiles.ts39
-rw-r--r--apps/files/src/services/PreviewService.ts21
-rw-r--r--apps/files/src/services/Recent.ts74
-rw-r--r--apps/files/src/services/RouterService.ts75
-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.js31
-rw-r--r--apps/files/src/services/Settings.js21
-rw-r--r--apps/files/src/services/Sidebar.js21
-rw-r--r--apps/files/src/services/Templates.js30
-rw-r--r--apps/files/src/services/WebDavSearch.ts83
-rw-r--r--apps/files/src/services/WebdavClient.ts19
22 files changed, 1279 insertions, 346 deletions
diff --git a/apps/files/src/services/DropService.ts b/apps/files/src/services/DropService.ts
new file mode 100644
index 00000000000..1013baeda6c
--- /dev/null
+++ b/apps/files/src/services/DropService.ts
@@ -0,0 +1,198 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Upload } from '@nextcloud/upload'
+import type { RootDirectory } from './DropServiceUtils'
+
+import { Folder, Node, NodeStatus, davRootPath } from '@nextcloud/files'
+import { getUploader, hasConflict } from '@nextcloud/upload'
+import { join } from 'path'
+import { joinPaths } from '@nextcloud/paths'
+import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+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.ts'
+
+/**
+ * This function converts a list of DataTransferItems to a file tree.
+ * It uses the Filesystem API if available, otherwise it falls back to the File API.
+ * The File API will NOT be available if the browser is not in a secure context (e.g. HTTP).
+ * ⚠️ When using this method, you need to use it as fast as possible, as the DataTransferItems
+ * will be cleared after the first access to the props of one of the entries.
+ *
+ * @param items the list of DataTransferItems
+ */
+export const dataTransferToFileTree = async (items: DataTransferItem[]): Promise<RootDirectory> => {
+ // Check if the browser supports the Filesystem API
+ // We need to cache the entries to prevent Blink engine bug that clears
+ // the list (`data.items`) after first access props of one of the entries
+ const entries = items
+ .filter((item) => {
+ if (item.kind !== 'file') {
+ logger.debug('Skipping dropped item', { kind: item.kind, type: item.type })
+ return false
+ }
+ return true
+ }).map((item) => {
+ // MDN recommends to try both, as it might be renamed in the future
+ return (item as unknown as { getAsEntry?: () => FileSystemEntry|undefined })?.getAsEntry?.()
+ ?? item?.webkitGetAsEntry?.()
+ ?? item
+ }) as (FileSystemEntry | DataTransferItem)[]
+
+ let warned = false
+ const fileTree = new Directory('root') as RootDirectory
+
+ // Traverse the file tree
+ for (const entry of entries) {
+ // Handle browser issues if Filesystem API is not available. Fallback to File API
+ if (entry instanceof DataTransferItem) {
+ logger.warn('Could not get FilesystemEntry of item, falling back to file')
+
+ const file = entry.getAsFile()
+ if (file === null) {
+ logger.warn('Could not process DataTransferItem', { type: entry.type, kind: entry.kind })
+ showError(t('files', 'One of the dropped files could not be processed'))
+ continue
+ }
+
+ // Warn the user that the browser does not support the Filesystem API
+ // we therefore cannot upload directories recursively.
+ if (file.type === 'httpd/unix-directory' || !file.type) {
+ if (!warned) {
+ logger.warn('Browser does not support Filesystem API. Directories will not be uploaded')
+ showWarning(t('files', 'Your browser does not support the Filesystem API. Directories will not be uploaded'))
+ warned = true
+ }
+ continue
+ }
+
+ fileTree.contents.push(file)
+ continue
+ }
+
+ // Use Filesystem API
+ try {
+ fileTree.contents.push(await traverseTree(entry))
+ } catch (error) {
+ // Do not throw, as we want to continue with the other files
+ logger.error('Error while traversing file tree', { error })
+ }
+ }
+
+ return fileTree
+}
+
+export const onDropExternalFiles = async (root: RootDirectory, destination: Folder, contents: Node[]): Promise<Upload[]> => {
+ const uploader = getUploader()
+
+ // Check for conflicts on root elements
+ if (await hasConflict(root.contents, contents)) {
+ root.contents = await resolveConflict(root.contents, destination, contents)
+ }
+
+ if (root.contents.length === 0) {
+ logger.info('No files to upload', { root })
+ showInfo(t('files', 'No files to upload'))
+ return []
+ }
+
+ // Let's process the files
+ logger.debug(`Uploading files to ${destination.path}`, { root, contents: root.contents })
+ const queue = [] as Promise<Upload>[]
+
+ const uploadDirectoryContents = async (directory: Directory, path: string) => {
+ for (const file of directory.contents) {
+ // This is the relative path to the resource
+ // from the current uploader destination
+ const relativePath = join(path, file.name)
+
+ // If the file is a directory, we need to create it first
+ // then browse its tree and upload its contents.
+ if (file instanceof Directory) {
+ const absolutePath = joinPaths(davRootPath, destination.path, relativePath)
+ try {
+ console.debug('Processing directory', { relativePath })
+ await createDirectoryIfNotExists(absolutePath)
+ await uploadDirectoryContents(file, relativePath)
+ } catch (error) {
+ showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
+ logger.error('', { error, absolutePath, directory: file })
+ }
+ continue
+ }
+
+ // If we've reached a file, we can upload it
+ logger.debug('Uploading file to ' + join(destination.path, relativePath), { file })
+
+ // Overriding the root to avoid changing the current uploader context
+ queue.push(uploader.upload(relativePath, file, destination.source))
+ }
+ }
+
+ // Pause the uploader to prevent it from starting
+ // while we compute the queue
+ uploader.pause()
+
+ // Upload the files. Using '/' as the starting point
+ // as we already adjusted the uploader destination
+ await uploadDirectoryContents(root, '/')
+ uploader.start()
+
+ // Wait for all promises to settle
+ const results = await Promise.allSettled(queue)
+
+ // Check for errors
+ const errors = results.filter(result => result.status === 'rejected')
+ if (errors.length > 0) {
+ logger.error('Error while uploading files', { errors })
+ showError(t('files', 'Some files could not be uploaded'))
+ return []
+ }
+
+ logger.debug('Files uploaded successfully')
+ showSuccess(t('files', 'Files uploaded successfully'))
+
+ return Promise.all(queue)
+}
+
+export const onDropInternalFiles = async (nodes: Node[], destination: Folder, contents: Node[], isCopy = false) => {
+ const queue = [] as Promise<void>[]
+
+ // Check for conflicts on root elements
+ if (await hasConflict(nodes, contents)) {
+ nodes = await resolveConflict(nodes, destination, contents)
+ }
+
+ if (nodes.length === 0) {
+ logger.info('No files to process', { nodes })
+ showInfo(t('files', 'No files to process'))
+ return
+ }
+
+ for (const node of nodes) {
+ Vue.set(node, 'status', NodeStatus.LOADING)
+ queue.push(handleCopyMoveNodeTo(node, destination, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE, true))
+ }
+
+ // Wait for all promises to settle
+ const results = await Promise.allSettled(queue)
+ nodes.forEach(node => Vue.set(node, 'status', undefined))
+
+ // Check for errors
+ const errors = results.filter(result => result.status === 'rejected')
+ if (errors.length > 0) {
+ logger.error('Error while copying or moving files', { errors })
+ showError(isCopy ? t('files', 'Some files could not be copied') : t('files', 'Some files could not be moved'))
+ return
+ }
+
+ logger.debug('Files copy/move successful')
+ showSuccess(isCopy ? t('files', 'Files copied successfully') : t('files', 'Files moved successfully'))
+}
diff --git a/apps/files/src/services/DropServiceUtils.spec.ts b/apps/files/src/services/DropServiceUtils.spec.ts
new file mode 100644
index 00000000000..5f4370c7894
--- /dev/null
+++ b/apps/files/src/services/DropServiceUtils.spec.ts
@@ -0,0 +1,143 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { FileSystemDirectoryEntry, FileSystemFileEntry, fileSystemEntryToDataTransferItem, DataTransferItem as DataTransferItemMock } from '../../../../__tests__/FileSystemAPIUtils'
+import { join } from 'node:path'
+import { Directory, traverseTree } from './DropServiceUtils'
+import { dataTransferToFileTree } from './DropService'
+import logger from '../logger'
+
+const dataTree = {
+ 'file0.txt': ['Hello, world!', 1234567890],
+ dir1: {
+ 'file1.txt': ['Hello, world!', 4567891230],
+ 'file2.txt': ['Hello, world!', 7891234560],
+ },
+ dir2: {
+ 'file3.txt': ['Hello, world!', 1234567890],
+ },
+}
+
+// This is mocking a file tree using the FileSystem API
+const buildFileSystemDirectoryEntry = (path: string, tree: any): FileSystemDirectoryEntry => {
+ const entries = Object.entries(tree).map(([name, contents]) => {
+ const fullPath = join(path, name)
+ if (Array.isArray(contents)) {
+ return new FileSystemFileEntry(fullPath, contents[0], contents[1])
+ } else {
+ return buildFileSystemDirectoryEntry(fullPath, contents)
+ }
+ })
+ return new FileSystemDirectoryEntry(path, entries)
+}
+
+const buildDataTransferItemArray = (path: string, tree: any, isFileSystemAPIAvailable = true): DataTransferItemMock[] => {
+ return Object.entries(tree).map(([name, contents]) => {
+ const fullPath = join(path, name)
+ if (Array.isArray(contents)) {
+ const entry = new FileSystemFileEntry(fullPath, contents[0], contents[1])
+ return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
+ }
+
+ const entry = buildFileSystemDirectoryEntry(fullPath, contents)
+ return fileSystemEntryToDataTransferItem(entry, isFileSystemAPIAvailable)
+ })
+}
+
+describe('Filesystem API traverseTree', () => {
+ it('Should traverse a file tree from root', async () => {
+ // Fake a FileSystemEntry tree
+ const root = buildFileSystemDirectoryEntry('root', dataTree)
+ const tree = await traverseTree(root as unknown as FileSystemEntry) as Directory
+
+ expect(tree.name).toBe('root')
+ expect(tree).toBeInstanceOf(Directory)
+ expect(tree.contents).toHaveLength(3)
+ expect(tree.size).toBe(13 * 4) // 13 bytes from 'Hello, world!'
+ })
+
+ it('Should traverse a file tree from a subdirectory', async () => {
+ // Fake a FileSystemEntry tree
+ const dir2 = buildFileSystemDirectoryEntry('dir2', dataTree.dir2)
+ const tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
+
+ expect(tree.name).toBe('dir2')
+ expect(tree).toBeInstanceOf(Directory)
+ expect(tree.contents).toHaveLength(1)
+ expect(tree.contents[0].name).toBe('file3.txt')
+ expect(tree.size).toBe(13) // 13 bytes from 'Hello, world!'
+ })
+
+ it('Should properly compute the last modified', async () => {
+ // Fake a FileSystemEntry tree
+ const root = buildFileSystemDirectoryEntry('root', dataTree)
+ const rootTree = await traverseTree(root as unknown as FileSystemEntry) as Directory
+
+ expect(rootTree.lastModified).toBe(7891234560)
+
+ // Fake a FileSystemEntry tree
+ const dir2 = buildFileSystemDirectoryEntry('root', dataTree.dir2)
+ const dir2Tree = await traverseTree(dir2 as unknown as FileSystemEntry) as Directory
+ expect(dir2Tree.lastModified).toBe(1234567890)
+ })
+})
+
+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
+ })
+
+ it('Should return a RootDirectory with Filesystem API', async () => {
+ 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[])
+
+ expect(fileTree.name).toBe('root')
+ expect(fileTree).toBeInstanceOf(Directory)
+ expect(fileTree.contents).toHaveLength(3)
+
+ // The file tree should be recursive when using the Filesystem API
+ expect(fileTree.contents[1]).toBeInstanceOf(Directory)
+ expect((fileTree.contents[1] as Directory).contents).toHaveLength(2)
+ expect(fileTree.contents[2]).toBeInstanceOf(Directory)
+ expect((fileTree.contents[2] as Directory).contents).toHaveLength(1)
+
+ expect(logger.error).not.toBeCalled()
+ expect(logger.warn).not.toBeCalled()
+ })
+
+ it('Should return a RootDirectory with legacy File API ignoring recursive directories', async () => {
+ vi.spyOn(logger, 'error').mockImplementation(() => vi.fn())
+ vi.spyOn(logger, 'warn').mockImplementation(() => vi.fn())
+
+ const dataTransferItems = buildDataTransferItemArray('root', dataTree, false)
+
+ const fileTree = await dataTransferToFileTree(dataTransferItems as unknown as DataTransferItem[])
+
+ expect(fileTree.name).toBe('root')
+ expect(fileTree).toBeInstanceOf(Directory)
+ expect(fileTree.contents).toHaveLength(1)
+
+ // The file tree should be recursive when using the Filesystem API
+ expect(fileTree.contents[0]).not.toBeInstanceOf(Directory)
+ expect((fileTree.contents[0].name)).toBe('file0.txt')
+
+ expect(logger.error).not.toBeCalled()
+ expect(logger.warn).toHaveBeenNthCalledWith(1, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenNthCalledWith(2, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenNthCalledWith(3, 'Browser does not support Filesystem API. Directories will not be uploaded')
+ expect(logger.warn).toHaveBeenNthCalledWith(4, 'Could not get FilesystemEntry of item, falling back to file')
+ expect(logger.warn).toHaveBeenCalledTimes(4)
+ })
+})
diff --git a/apps/files/src/services/DropServiceUtils.ts b/apps/files/src/services/DropServiceUtils.ts
new file mode 100644
index 00000000000..f10a09cfe27
--- /dev/null
+++ b/apps/files/src/services/DropServiceUtils.ts
@@ -0,0 +1,178 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import { emit } from '@nextcloud/event-bus'
+import { Folder, Node, davGetClient, davGetDefaultPropfind, davResultToNode } from '@nextcloud/files'
+import { openConflictPicker } from '@nextcloud/upload'
+import { showError, showInfo } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+import logger from '../logger.ts'
+
+/**
+ * This represents a Directory in the file tree
+ * We extend the File class to better handling uploading
+ * and stay as close as possible as the Filesystem API.
+ * This also allow us to hijack the size or lastModified
+ * properties to compute them dynamically.
+ */
+export class Directory extends File {
+
+ /* eslint-disable no-use-before-define */
+ _contents: (Directory|File)[]
+
+ constructor(name, contents: (Directory|File)[] = []) {
+ super([], name, { type: 'httpd/unix-directory' })
+ this._contents = contents
+ }
+
+ set contents(contents: (Directory|File)[]) {
+ this._contents = contents
+ }
+
+ get contents(): (Directory|File)[] {
+ return this._contents
+ }
+
+ get size() {
+ return this._computeDirectorySize(this)
+ }
+
+ get lastModified() {
+ if (this._contents.length === 0) {
+ return Date.now()
+ }
+ return this._computeDirectoryMtime(this)
+ }
+
+ /**
+ * Get the last modification time of a file tree
+ * This is not perfect, but will get us a pretty good approximation
+ * @param directory the directory to traverse
+ */
+ _computeDirectoryMtime(directory: Directory): number {
+ return directory.contents.reduce((acc, file) => {
+ return file.lastModified > acc
+ // If the file is a directory, the lastModified will
+ // also return the results of its _computeDirectoryMtime method
+ // Fancy recursion, huh?
+ ? file.lastModified
+ : acc
+ }, 0)
+ }
+
+ /**
+ * Get the size of a file tree
+ * @param directory the directory to traverse
+ */
+ _computeDirectorySize(directory: Directory): number {
+ return directory.contents.reduce((acc: number, entry: Directory|File) => {
+ // If the file is a directory, the size will
+ // also return the results of its _computeDirectorySize method
+ // Fancy recursion, huh?
+ return acc + entry.size
+ }, 0)
+ }
+
+}
+
+export type RootDirectory = Directory & {
+ name: 'root'
+}
+
+/**
+ * Traverse a file tree using the Filesystem API
+ * @param entry the entry to traverse
+ */
+export const traverseTree = async (entry: FileSystemEntry): Promise<Directory|File> => {
+ // Handle file
+ if (entry.isFile) {
+ return new Promise<File>((resolve, reject) => {
+ (entry as FileSystemFileEntry).file(resolve, reject)
+ })
+ }
+
+ // Handle directory
+ logger.debug('Handling recursive file tree', { entry: entry.name })
+ const directory = entry as FileSystemDirectoryEntry
+ const entries = await readDirectory(directory)
+ const contents = (await Promise.all(entries.map(traverseTree))).flat()
+ return new Directory(directory.name, contents)
+}
+
+/**
+ * Read a directory using Filesystem API
+ * @param directory the directory to read
+ */
+const readDirectory = (directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> => {
+ const dirReader = directory.createReader()
+
+ return new Promise<FileSystemEntry[]>((resolve, reject) => {
+ const entries = [] as FileSystemEntry[]
+ const getEntries = () => {
+ dirReader.readEntries((results) => {
+ if (results.length) {
+ entries.push(...results)
+ getEntries()
+ } else {
+ resolve(entries)
+ }
+ }, (error) => {
+ reject(error)
+ })
+ }
+
+ getEntries()
+ })
+}
+
+export const createDirectoryIfNotExists = async (absolutePath: string) => {
+ const davClient = davGetClient()
+ const dirExists = await davClient.exists(absolutePath)
+ if (!dirExists) {
+ logger.debug('Directory does not exist, creating it', { absolutePath })
+ await davClient.createDirectory(absolutePath, { recursive: true })
+ const stat = await davClient.stat(absolutePath, { details: true, data: davGetDefaultPropfind() }) as ResponseDataDetailed<FileStat>
+ emit('files:node:created', davResultToNode(stat.data))
+ }
+}
+
+export const resolveConflict = async <T extends ((Directory|File)|Node)>(files: Array<T>, destination: Folder, contents: Node[]): Promise<T[]> => {
+ try {
+ // List all conflicting files
+ const conflicts = files.filter((file: File|Node) => {
+ return contents.find((node: Node) => node.basename === (file instanceof File ? file.name : file.basename))
+ }).filter(Boolean) as (File|Node)[]
+
+ // List of incoming files that are NOT in conflict
+ const uploads = files.filter((file: File|Node) => {
+ return !conflicts.includes(file)
+ })
+
+ // Let the user choose what to do with the conflicting files
+ const { selected, renamed } = await openConflictPicker(destination.path, conflicts, contents)
+
+ logger.debug('Conflict resolution', { uploads, selected, renamed })
+
+ // If the user selected nothing, we cancel the upload
+ if (selected.length === 0 && renamed.length === 0) {
+ // User skipped
+ showInfo(t('files', 'Conflicts resolution skipped'))
+ logger.info('User skipped the conflict resolution')
+ return []
+ }
+
+ // Update the list of files to upload
+ return [...uploads, ...selected, ...renamed] as (typeof files)
+ } catch (error) {
+ console.error(error)
+ // User cancelled
+ showError(t('files', 'Upload cancelled'))
+ logger.error('User cancelled the upload')
+ }
+
+ return []
+}
diff --git a/apps/files/src/services/Favorites.ts b/apps/files/src/services/Favorites.ts
new file mode 100644
index 00000000000..e156c92c511
--- /dev/null
+++ b/apps/files/src/services/Favorites.ts
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2023 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, davRemoteURL, davRootPath, getFavoriteNodes } from '@nextcloud/files'
+import { CancelablePromise } from 'cancelable-promise'
+import { getContents as filesContents } from './Files.ts'
+import { client } from './WebdavClient.ts'
+
+export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
+ // We only filter root files for favorites, for subfolders we can simply reuse the files contents
+ if (path !== '/') {
+ return filesContents(path)
+ }
+
+ return new CancelablePromise((resolve, reject, cancel) => {
+ const promise = getFavoriteNodes(client)
+ .catch(reject)
+ .then((contents) => {
+ if (!contents) {
+ reject()
+ return
+ }
+ resolve({
+ contents,
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ root: davRootPath,
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.READ,
+ }),
+ })
+ })
+ cancel(() => promise.cancel())
+ })
+}
diff --git a/apps/files/src/services/FileInfo.js b/apps/files/src/services/FileInfo.js
deleted file mode 100644
index c09af45f495..00000000000
--- a/apps/files/src/services/FileInfo.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import axios from '@nextcloud/axios'
-
-/**
- * @param {any} url -
- */
-export default async function(url) {
- const response = await axios({
- method: 'PROPFIND',
- url,
- data: `<?xml version="1.0"?>
- <d:propfind xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:prop>
- <d:getlastmodified />
- <d:getetag />
- <d:getcontenttype />
- <d:resourcetype />
- <oc:fileid />
- <oc:permissions />
- <oc:size />
- <d:getcontentlength />
- <nc:has-preview />
- <nc:mount-type />
- <nc:is-encrypted />
- <ocs:share-permissions />
- <nc:share-attributes />
- <oc:tags />
- <oc:favorite />
- <oc:comments-unread />
- <oc:owner-id />
- <oc:owner-display-name />
- <oc:share-types />
- </d:prop>
- </d:propfind>`,
- })
-
- // TODO: create new parser or use cdav-lib when available
- const file = OCA.Files.App.fileList.filesClient._client.parseMultiStatus(response.data)
- // TODO: create new parser or use cdav-lib when available
- const fileInfo = OCA.Files.App.fileList.filesClient._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
new file mode 100644
index 00000000000..080ce91e538
--- /dev/null
+++ b/apps/files/src/services/Files.ts
@@ -0,0 +1,110 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+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 { 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): Node => davResultToNode(stat)
+
+/**
+ * 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 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())
+
+ try {
+ const contentsResponse = await client.getDirectoryContents(path, {
+ details: true,
+ data: propfindPayload,
+ includeSelf: true,
+ signal: controller.signal,
+ }) as ResponseDataDetailed<FileStat[]>
+
+ const root = contentsResponse.data[0]
+ const contents = contentsResponse.data.slice(1)
+ if (root.filename !== path && `${root.filename}/` !== path) {
+ logger.debug(`Exepected "${path}" but got filename "${root.filename}" instead.`)
+ throw new Error('Root node does not match requested path')
+ }
+
+ resolve({
+ folder: resultToNode(root) as Folder,
+ contents: contents.map((result) => {
+ try {
+ return resultToNode(result)
+ } catch (error) {
+ logger.error(`Invalid node detected '${result.basename}'`, { error })
+ return null
+ }
+ }).filter(Boolean) as File[],
+ })
+ } catch (error) {
+ reject(error)
+ }
+ })
+}
+
+/**
+ * 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
new file mode 100644
index 00000000000..10be42444e2
--- /dev/null
+++ b/apps/files/src/services/LivePhotos.ts
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { Node, registerDavProperty } from '@nextcloud/files'
+
+/**
+ *
+ */
+export function initLivePhotos(): void {
+ registerDavProperty('nc:metadata-files-live-photo', { nc: 'http://nextcloud.org/ns' })
+}
+
+/**
+ * @param {Node} node - The node
+ */
+export function isLivePhoto(node: Node): boolean {
+ return node.attributes['metadata-files-live-photo'] !== undefined
+}
diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts
deleted file mode 100644
index e3286c79a88..00000000000
--- a/apps/files/src/services/Navigation.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-/**
- * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-import type Node from '@nextcloud/files/dist/files/node'
-import isSvg from 'is-svg'
-
-import logger from '../logger'
-
-export interface Column {
- /** Unique column ID */
- id: string
- /** Translated column title */
- title: string
- /** Property key from Node main or additional attributes.
- Will be used if no custom sort function is provided.
- Sorting will be done by localCompare */
- property: string
- /** Special function used to sort Nodes between them */
- sortFunction?: (nodeA: Node, nodeB: Node) => number;
- /** Custom summary of the column to display at the end of the list.
- Will not be displayed if nothing is provided */
- summary?: (node: Node[]) => string
-}
-
-export interface Navigation {
- /** Unique view ID */
- id: string
- /** Translated view name */
- name: string
- /** Method return the content of the provided path */
- getFiles: (path: string) => Node[]
- /** The view icon as an inline svg */
- icon: string
- /** The view order */
- order: number
- /** This view column(s). Name and actions are
- by default always included */
- columns?: Column[]
- /** The empty view element to render your empty content into */
- emptyView?: (div: HTMLDivElement) => void
- /** The parent unique ID */
- parent?: string
- /** This view is sticky (sent at the bottom) */
- sticky?: boolean
- /** This view has children and is expanded or not */
- expanded?: boolean
-
- /**
- * This view is sticky a legacy view.
- * Here until all the views are migrated to Vue.
- * @deprecated It will be removed in a near future
- */
- legacy?: boolean
- /**
- * An icon class.
- * @deprecated It will be removed in a near future
- */
- iconClass?: string
-}
-
-export default class {
-
- private _views: Navigation[] = []
- private _currentView: Navigation | null = null
-
- constructor() {
- logger.debug('Navigation service initialized')
- }
-
- register(view: Navigation) {
- try {
- isValidNavigation(view)
- isUniqueNavigation(view, this._views)
- } catch (e) {
- if (e instanceof Error) {
- logger.error(e.message, { view })
- }
- throw e
- }
-
- if (view.legacy) {
- logger.warn('Legacy view detected, please migrate to Vue')
- }
-
- if (view.iconClass) {
- view.legacy = true
- }
-
- this._views.push(view)
- }
-
- get views(): Navigation[] {
- return this._views
- }
-
- setActive(view: Navigation | null) {
- this._currentView = view
- }
-
- get active(): Navigation | null {
- return this._currentView
- }
-
-}
-
-/**
- * Make sure the given view is unique
- * and not already registered.
- */
-const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean {
- if (views.find(search => search.id === view.id)) {
- throw new Error(`Navigation id ${view.id} is already registered`)
- }
- return true
-}
-
-/**
- * Typescript cannot validate an interface.
- * Please keep in sync with the Navigation interface requirements.
- */
-const isValidNavigation = function(view: Navigation): boolean {
- if (!view.id || typeof view.id !== 'string') {
- throw new Error('Navigation id is required and must be a string')
- }
-
- if (!view.name || typeof view.name !== 'string') {
- throw new Error('Navigation name is required and must be a string')
- }
-
- /**
- * Legacy handle their content and icon differently
- * TODO: remove when support for legacy views is removed
- */
- if (!view.legacy) {
- if (!view.getFiles || typeof view.getFiles !== 'function') {
- throw new Error('Navigation getFiles is required and must be a function')
- }
-
- if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
- throw new Error('Navigation icon is required and must be a valid svg string')
- }
- }
-
- if (!('order' in view) || typeof view.order !== 'number') {
- throw new Error('Navigation order is required and must be a number')
- }
-
- // Optional properties
- if (view.columns) {
- view.columns.forEach(isValidColumn)
- }
-
- if (view.emptyView && typeof view.emptyView !== 'function') {
- throw new Error('Navigation emptyView must be a function')
- }
-
- if (view.parent && typeof view.parent !== 'string') {
- throw new Error('Navigation parent must be a string')
- }
-
- if ('sticky' in view && typeof view.sticky !== 'boolean') {
- throw new Error('Navigation sticky must be a boolean')
- }
-
- if ('expanded' in view && typeof view.expanded !== 'boolean') {
- throw new Error('Navigation expanded must be a boolean')
- }
-
- return true
-}
-
-/**
- * Typescript cannot validate an interface.
- * Please keep in sync with the Column interface requirements.
- */
-const isValidColumn = function(column: Column): boolean {
- if (!column.id || typeof column.id !== 'string') {
- throw new Error('Column id is required')
- }
-
- if (!column.title || typeof column.title !== 'string') {
- throw new Error('Column title is required')
- }
-
- if (!column.property || typeof column.property !== 'string') {
- throw new Error('Column property is required')
- }
-
- // Optional properties
- if (column.sortFunction && typeof column.sortFunction !== 'function') {
- throw new Error('Column sortFunction must be a function')
- }
-
- if (column.summary && typeof column.summary !== 'function') {
- throw new Error('Column summary must be a function')
- }
-
- return true
-}
diff --git a/apps/files/src/services/PersonalFiles.ts b/apps/files/src/services/PersonalFiles.ts
new file mode 100644
index 00000000000..6d86bd3bae2
--- /dev/null
+++ b/apps/files/src/services/PersonalFiles.ts
@@ -0,0 +1,39 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, ContentsWithRoot } from '@nextcloud/files'
+import type { CancelablePromise } from 'cancelable-promise'
+import { getCurrentUser } from '@nextcloud/auth'
+
+import { getContents as getFiles } from './Files'
+
+const currentUserId = getCurrentUser()?.uid
+
+/**
+ * Filters each file/folder on its shared status
+ *
+ * A personal file is considered a file that has all of the following properties:
+ * 1. the current user owns
+ * 2. the file is not shared with anyone
+ * 3. the file is not a group folder
+ * @todo Move to `@nextcloud/files`
+ * @param node The node to check
+ */
+export const isPersonalFile = function(node: Node): boolean {
+ // the type of mounts that determine whether the file is shared
+ const sharedMountTypes = ['group', 'shared']
+ const mountType = node.attributes['mount-type']
+
+ return currentUserId === node.owner && !sharedMountTypes.includes(mountType)
+}
+
+export const getContents = (path: string = '/'): CancelablePromise<ContentsWithRoot> => {
+ // get all the files from the current path as a cancellable promise
+ // then filter the files that the user does not own, or has shared / is a group folder
+ return getFiles(path)
+ .then((content) => {
+ content.contents = content.contents.filter(isPersonalFile)
+ return content
+ })
+}
diff --git a/apps/files/src/services/PreviewService.ts b/apps/files/src/services/PreviewService.ts
new file mode 100644
index 00000000000..6dbb67f30b6
--- /dev/null
+++ b/apps/files/src/services/PreviewService.ts
@@ -0,0 +1,21 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// The preview service worker cache name (see webpack config)
+const SWCacheName = 'previews'
+
+/**
+ * Check if the preview is already cached by the service worker
+ * @param previewUrl URL to check
+ */
+export async function isCachedPreview(previewUrl: string): Promise<boolean> {
+ if (!window?.caches?.open) {
+ return false
+ }
+
+ 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
new file mode 100644
index 00000000000..d0ca285b05c
--- /dev/null
+++ b/apps/files/src/services/Recent.ts
@@ -0,0 +1,74 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ContentsWithRoot, Node } from '@nextcloud/files'
+import type { FileStat, ResponseDataDetailed, SearchResult } from 'webdav'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, Permission, davGetRecentSearch, davRootPath, davRemoteURL, davResultToNode } from '@nextcloud/files'
+import { CancelablePromise } from 'cancelable-promise'
+import { useUserConfigStore } from '../store/userconfig.ts'
+import { getPinia } from '../store/index.ts'
+import { client } from './WebdavClient.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.
+ * If hidden files are not shown, then also recently changed files *in* hidden directories are filtered.
+ *
+ * @param path Path to search for recent changes
+ */
+export const getContents = (path = '/'): CancelablePromise<ContentsWithRoot> => {
+ const store = useUserConfigStore(getPinia())
+
+ /**
+ * Filter function that returns only the visible nodes - or hidden if explicitly configured
+ * @param node The node to check
+ */
+ const filterHidden = (node: Node) =>
+ path !== '/' // We need to hide files from hidden directories in the root if not configured to show
+ || store.userConfig.show_hidden // If configured to show hidden files we can early return
+ || !node.dirname.split('/').some((dir) => dir.startsWith('.')) // otherwise only include the file if non of the parent directories is hidden
+
+ const controller = new AbortController()
+ const handler = async () => {
+ const contentsResponse = await client.search('/', {
+ signal: controller.signal,
+ details: true,
+ data: davGetRecentSearch(lastTwoWeeksTimestamp),
+ }) as ResponseDataDetailed<SearchResult>
+
+ const contents = contentsResponse.data.results
+ .map(resultToNode)
+ .filter(filterHidden)
+
+ return {
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ root: davRootPath,
+ owner: getCurrentUser()?.uid || null,
+ permissions: Permission.READ,
+ }),
+ contents,
+ }
+ }
+
+ return new CancelablePromise(async (resolve, reject, cancel) => {
+ cancel(() => controller.abort())
+ resolve(handler())
+ })
+}
diff --git a/apps/files/src/services/RouterService.ts b/apps/files/src/services/RouterService.ts
new file mode 100644
index 00000000000..4e2999b1d29
--- /dev/null
+++ b/apps/files/src/services/RouterService.ts
@@ -0,0 +1,75 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Route, Location } from 'vue-router'
+import type VueRouter from 'vue-router'
+
+export default class RouterService {
+
+ // 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
+ }
+
+ get name(): string | null | undefined {
+ return this.router.currentRoute.name
+ }
+
+ get query(): Record<string, string | (string | null)[] | null | undefined> {
+ return this.router.currentRoute.query || {}
+ }
+
+ 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
+ }
+
+ /**
+ * Trigger a route change on the files app
+ *
+ * @param path the url path, eg: '/trashbin?dir=/Deleted'
+ * @param replace replace the current history
+ * @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({
+ path,
+ replace,
+ })
+ }
+
+ /**
+ * Trigger a route change on the files App
+ *
+ * @param name the route name
+ * @param params the route parameters
+ * @param query the url query parameters
+ * @param replace replace the current history
+ * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location
+ */
+ goToRoute(
+ name?: string,
+ params?: Record<string, string>,
+ query?: Record<string, string | (string | null)[] | null | undefined>,
+ replace?: boolean,
+ ): Promise<Route> {
+ return this.router.push({
+ name,
+ query,
+ params,
+ replace,
+ } as Location)
+ }
+
+}
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
new file mode 100644
index 00000000000..cc13db44009
--- /dev/null
+++ b/apps/files/src/services/ServiceWorker.js
@@ -0,0 +1,31 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import logger from '../logger.ts'
+
+export default () => {
+ if ('serviceWorker' in navigator) {
+ // Use the window load event to keep the page load performant
+ window.addEventListener('load', async () => {
+ try {
+ const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true })
+ 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 })
+ }
+ })
+ } else {
+ logger.debug('Service Worker is not enabled on this browser.')
+ }
+}
diff --git a/apps/files/src/services/Settings.js b/apps/files/src/services/Settings.js
index 83c2c850580..7f04aa82fda 100644
--- a/apps/files/src/services/Settings.js
+++ b/apps/files/src/services/Settings.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- *
- * @author Gary Kim <gary@garykim.dev>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default class Settings {
diff --git a/apps/files/src/services/Sidebar.js b/apps/files/src/services/Sidebar.js
index e87ee71a4b1..0f5c275e532 100644
--- a/apps/files/src/services/Sidebar.js
+++ b/apps/files/src/services/Sidebar.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default class Sidebar {
diff --git a/apps/files/src/services/Templates.js b/apps/files/src/services/Templates.js
index c242f9ae82d..d7f25846ceb 100644
--- a/apps/files/src/services/Templates.js
+++ b/apps/files/src/services/Templates.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateOcsUrl } from '@nextcloud/router'
@@ -28,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
new file mode 100644
index 00000000000..2b92deba9b4
--- /dev/null
+++ b/apps/files/src/services/WebdavClient.ts
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+import type { Node } from '@nextcloud/files'
+
+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)
+}