diff options
Diffstat (limited to 'apps/files/src/services')
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) +} |