Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(files): add uploader #39945

Merged
merged 1 commit into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/files/src/components/BreadCrumbs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
<Home :size="20" />
</template>
</NcBreadcrumb>

<!-- Forward the actions slot -->
<template #actions>
<slot name="actions" />
</template>
</NcBreadcrumbs>
</template>

Expand Down
24 changes: 16 additions & 8 deletions apps/files/src/components/FileEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ import { debounce } from 'debounce'
import { emit } from '@nextcloud/event-bus'
import { extname } from 'path'
import { generateUrl } from '@nextcloud/router'
import { getFileActions, DefaultType, FileType, formatFileSize, Permission } from '@nextcloud/files'
import { getFileActions, DefaultType, FileType, formatFileSize, Permission, NodeStatus } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate } from '@nextcloud/l10n'
import { vOnClickOutside } from '@vueuse/components'
Expand Down Expand Up @@ -521,8 +521,10 @@ export default Vue.extend({
* If renaming starts, select the file name
* in the input, without the extension.
*/
isRenaming() {
this.startRenaming()
isRenaming(renaming) {
if (renaming) {
this.startRenaming()
}
},
},

Expand Down Expand Up @@ -718,9 +720,10 @@ export default Vue.extend({
* input validity using browser's native validation.
* @param event the keyup event
*/
checkInputValidity(event: KeyboardEvent) {
const input = event?.target as HTMLInputElement
checkInputValidity(event?: KeyboardEvent) {
const input = event.target as HTMLInputElement
const newName = this.newName.trim?.() || ''
logger.debug('Checking input validity', { newName })
try {
this.isFileNameValid(newName)
input.setCustomValidity('')
Expand Down Expand Up @@ -753,17 +756,20 @@ export default Vue.extend({
},

startRenaming() {
this.checkInputValidity()
this.$nextTick(() => {
const extLength = (this.source.extension || '').length
const length = this.source.basename.length - extLength
// Using split to get the true string length
const extLength = (this.source.extension || '').split('').length
const length = this.source.basename.split('').length - extLength
const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
if (!input) {
logger.error('Could not find the rename input')
return
}
input.setSelectionRange(0, length)
input.focus()

// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
stopRenaming() {
Expand Down Expand Up @@ -816,6 +822,8 @@ export default Vue.extend({
emit('files:node:updated', this.source)
emit('files:node:renamed', this.source)
showSuccess(this.t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))

// Reset the renaming store
this.stopRenaming()
this.$nextTick(() => {
this.$refs.basename.focus()
Expand Down
5 changes: 5 additions & 0 deletions apps/files/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import './actions/openInFilesAction.js'
import './actions/renameAction'
import './actions/sidebarAction'
import './actions/viewInFolderAction'
import './newMenu/newFolder'

import Vue from 'vue'
import { createPinia, PiniaVuePlugin } from 'pinia'
import { getNavigation } from '@nextcloud/files'
import { getRequestToken } from '@nextcloud/auth'

import FilesListView from './views/FilesList.vue'
import NavigationView from './views/Navigation.vue'
Expand All @@ -26,6 +28,9 @@ import RouterService from './services/RouterService'
import SettingsModel from './models/Setting.js'
import SettingsService from './services/Settings.js'

// @ts-expect-error __webpack_nonce__ is injected by webpack
__webpack_nonce__ = btoa(getRequestToken())

declare global {
interface Window {
OC: any;
Expand Down
96 changes: 96 additions & 0 deletions apps/files/src/newMenu/newFolder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @copyright Copyright (c) 2023 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 { Entry, Node } from '@nextcloud/files'

import { addNewFileMenuEntry, Permission, Folder } from '@nextcloud/files'
import { basename, extname } from 'path'
import { emit } from '@nextcloud/event-bus'
import { getCurrentUser } from '@nextcloud/auth'
import { showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import FolderPlusSvg from '@mdi/svg/svg/folder-plus.svg?raw'
import Vue from 'vue'

type createFolderResponse = {
fileid: number
source: string
}

const createNewFolder = async (root: string, name: string): Promise<createFolderResponse> => {
const source = root + '/' + name
const response = await axios({
method: 'MKCOL',
url: source,
headers: {
Overwrite: 'F',
},
})
return {
fileid: parseInt(response.headers['oc-fileid']),
source,
}
}

// TODO: move to @nextcloud/files
export const getUniqueName = (name: string, names: string[]): string => {
let newName = name
let i = 1
while (names.includes(newName)) {
const ext = extname(name)
newName = `${basename(name, ext)} (${i++})${ext}`
}
return newName
}

const entry = {
id: 'newFolder',
displayName: t('files', 'New folder'),
if: (context: Folder) => (context.permissions & Permission.CREATE) !== 0,
iconSvgInline: FolderPlusSvg,
async handler(context: Folder, content: Node[]) {
const contentNames = content.map((node: Node) => node.basename)
const name = getUniqueName(t('files', 'New Folder'), contentNames)
const { fileid, source } = await createNewFolder(context.source, name)

// Create the folder in the store
const folder = new Folder({
source,
id: fileid,
mtime: new Date(),
owner: getCurrentUser()?.uid || null,
permissions: Permission.ALL,
root: context?.root || '/files/' + getCurrentUser()?.uid,
})

if (!context._children) {
Vue.set(context, '_children', [])
}
context._children.push(folder.fileid)

showSuccess(t('files', 'Created new folder "{name}"', { name: basename(source) }))
emit('files:node:created', folder)
emit('files:node:rename', folder)
},
} as Entry

addNewFileMenuEntry(entry)
54 changes: 32 additions & 22 deletions apps/files/src/services/Files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import type { ContentsWithRoot } from '@nextcloud/files'
import type { FileStat, ResponseDataDetailed, DAVResultResponseProps } from 'webdav'

import { cancelable, CancelablePromise } from 'cancelable-promise'
import { File, Folder, davParsePermissions } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
Expand Down Expand Up @@ -73,30 +74,39 @@ const resultToNode = function(node: FileStat): File | Folder {
: new Folder(nodeData)
}

export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
export const getContents = (path = '/'): Promise<ContentsWithRoot> => {
const controller = new AbortController()
const propfindPayload = getDefaultPropfind()

const contentsResponse = await client.getDirectoryContents(path, {
details: true,
data: propfindPayload,
includeSelf: true,
}) as ResponseDataDetailed<FileStat[]>
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) {
throw new Error('Root node does not match requested path')
}

return {
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
const root = contentsResponse.data[0]
const contents = contentsResponse.data.slice(1)
if (root.filename !== path) {
throw new Error('Root node does not match requested path')
}
}).filter(Boolean) as File[],
}

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)
}
})
}
6 changes: 5 additions & 1 deletion apps/files/src/store/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,17 @@ export const useFilesStore = function(...args) {
onDeletedNode(node: Node) {
this.deleteNodes([node])
},

onCreatedNode(node: Node) {
this.updateNodes([node])
},
},
})

const fileStore = store(...args)
// Make sure we only register the listeners once
if (!fileStore._initialized) {
// subscribe('files:node:created', fileStore.onCreatedNode)
subscribe('files:node:created', fileStore.onCreatedNode)
subscribe('files:node:deleted', fileStore.onDeletedNode)
// subscribe('files:node:moved', fileStore.onMovedNode)
// subscribe('files:node:updated', fileStore.onUpdatedNode)
Expand Down
18 changes: 17 additions & 1 deletion apps/files/src/store/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { Node, getNavigation } from '@nextcloud/files'
import type { FileId, PathsStore, PathOptions, ServicesState } from '../types'
import { defineStore } from 'pinia'
import Vue from 'vue'
import logger from '../logger'
import { subscribe } from '@nextcloud/event-bus'

export const usePathsStore = function(...args) {
const store = defineStore('paths', {
Expand Down Expand Up @@ -50,14 +53,27 @@ export const usePathsStore = function(...args) {
// Now we can set the provided path
Vue.set(this.paths[payload.service], payload.path, payload.fileid)
},

onCreatedNode(node: Node) {
const currentView = getNavigation().active
if (!node.fileid) {
logger.error('Node has no fileid', { node })
return
}
this.addPath({
service: currentView?.id || 'files',
path: node.path,
fileid: node.fileid,
})
},
},
})

const pathsStore = store(...args)
// Make sure we only register the listeners once
if (!pathsStore._initialized) {
// TODO: watch folders to update paths?
// subscribe('files:node:created', pathsStore.onCreatedNode)
subscribe('files:node:created', pathsStore.onCreatedNode)
// subscribe('files:node:deleted', pathsStore.onDeletedNode)
// subscribe('files:node:moved', pathsStore.onMovedNode)

Expand Down
41 changes: 41 additions & 0 deletions apps/files/src/store/uploader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @copyright Copyright (c) 2023 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 { Uploader } from '@nextcloud/upload'
import type { UploaderStore } from '../types'

import { defineStore } from 'pinia'
import { getUploader } from '@nextcloud/upload'

let uploader: Uploader

export const useUploaderStore = function(...args) {
// Only init on runtime
uploader = getUploader()

const store = defineStore('uploader', {
state: () => ({
queue: uploader.queue,
} as UploaderStore),
})

return store(...args)
}
6 changes: 6 additions & 0 deletions apps/files/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*
*/
import type { Folder, Node } from '@nextcloud/files'
import type { Upload } from '@nextcloud/upload'

// Global definitions
export type Service = string
Expand Down Expand Up @@ -100,3 +101,8 @@ export interface RenamingStore {
renamingNode?: Node
newName: string
}

// Uploader store
export interface UploaderStore {
queue: Upload[]
}
Loading
Loading