aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-08-17 20:00:51 +0200
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-09-01 14:35:40 +0200
commit52590a762f10584f7d5f89a8ff1e8668aadee7d0 (patch)
treeb91f47c51872a40433ee3d5f9ee36731b069d81d /apps
parent4711c775b8267f831a4674718228927bca8fc6e4 (diff)
downloadnextcloud-server-52590a762f10584f7d5f89a8ff1e8668aadee7d0.tar.gz
nextcloud-server-52590a762f10584f7d5f89a8ff1e8668aadee7d0.zip
feat(files): add uploader
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/files/src/components/BreadCrumbs.vue5
-rw-r--r--apps/files/src/components/FileEntry.vue24
-rw-r--r--apps/files/src/main.ts5
-rw-r--r--apps/files/src/newMenu/newFolder.ts96
-rw-r--r--apps/files/src/services/Files.ts54
-rw-r--r--apps/files/src/store/files.ts6
-rw-r--r--apps/files/src/store/paths.ts18
-rw-r--r--apps/files/src/store/uploader.ts41
-rw-r--r--apps/files/src/types.ts6
-rw-r--r--apps/files/src/views/FilesList.vue42
10 files changed, 262 insertions, 35 deletions
diff --git a/apps/files/src/components/BreadCrumbs.vue b/apps/files/src/components/BreadCrumbs.vue
index c2938c5aca2..ec2041bf8ad 100644
--- a/apps/files/src/components/BreadCrumbs.vue
+++ b/apps/files/src/components/BreadCrumbs.vue
@@ -11,6 +11,11 @@
<Home :size="20" />
</template>
</NcBreadcrumb>
+
+ <!-- Forward the actions slot -->
+ <template #actions>
+ <slot name="actions" />
+ </template>
</NcBreadcrumbs>
</template>
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index 216d6bf2cd5..fce8b7ed263 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -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'
@@ -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()
+ }
},
},
@@ -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('')
@@ -753,10 +756,10 @@ 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')
@@ -764,6 +767,9 @@ export default Vue.extend({
}
input.setSelectionRange(0, length)
input.focus()
+
+ // Trigger a keyup event to update the input validity
+ input.dispatchEvent(new Event('keyup'))
})
},
stopRenaming() {
@@ -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()
diff --git a/apps/files/src/main.ts b/apps/files/src/main.ts
index 593baa49323..8bcfacf953a 100644
--- a/apps/files/src/main.ts
+++ b/apps/files/src/main.ts
@@ -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'
@@ -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;
diff --git a/apps/files/src/newMenu/newFolder.ts b/apps/files/src/newMenu/newFolder.ts
new file mode 100644
index 00000000000..399e6c1649a
--- /dev/null
+++ b/apps/files/src/newMenu/newFolder.ts
@@ -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)
diff --git a/apps/files/src/services/Files.ts b/apps/files/src/services/Files.ts
index 93325decc9c..d392dbb7751 100644
--- a/apps/files/src/services/Files.ts
+++ b/apps/files/src/services/Files.ts
@@ -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'
@@ -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)
+ }
+ })
}
diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts
index c36ebcfecc2..8653cc8e449 100644
--- a/apps/files/src/store/files.ts
+++ b/apps/files/src/store/files.ts
@@ -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)
diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts
index 6164e664498..1b86c69ac57 100644
--- a/apps/files/src/store/paths.ts
+++ b/apps/files/src/store/paths.ts
@@ -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', {
@@ -50,6 +53,19 @@ 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,
+ })
+ },
},
})
@@ -57,7 +73,7 @@ export const usePathsStore = function(...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)
diff --git a/apps/files/src/store/uploader.ts b/apps/files/src/store/uploader.ts
new file mode 100644
index 00000000000..db61dac77a7
--- /dev/null
+++ b/apps/files/src/store/uploader.ts
@@ -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)
+}
diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts
index 8035d9dc198..bf9f3a09648 100644
--- a/apps/files/src/types.ts
+++ b/apps/files/src/types.ts
@@ -20,6 +20,7 @@
*
*/
import type { Folder, Node } from '@nextcloud/files'
+import type { Upload } from '@nextcloud/upload'
// Global definitions
export type Service = string
@@ -100,3 +101,8 @@ export interface RenamingStore {
renamingNode?: Node
newName: string
}
+
+// Uploader store
+export interface UploaderStore {
+ queue: Upload[]
+}
diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue
index 67dffb8773e..fbd483fd23f 100644
--- a/apps/files/src/views/FilesList.vue
+++ b/apps/files/src/views/FilesList.vue
@@ -23,7 +23,16 @@
<NcAppContent data-cy-files-content>
<div class="files-list__header">
<!-- Current folder breadcrumbs -->
- <BreadCrumbs :path="dir" @reload="fetchContent" />
+ <BreadCrumbs :path="dir" @reload="fetchContent">
+ <template #actions>
+ <!-- Uploader -->
+ <UploadPicker v-if="currentFolder"
+ :content="dirContents"
+ :destination="currentFolder"
+ :multiple="true"
+ @uploaded="onUpload" />
+ </template>
+ </BreadCrumbs>
<!-- Secondary loading indicator -->
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
@@ -64,11 +73,15 @@
<script lang="ts">
import type { Route } from 'vue-router'
+import type { Upload } from '@nextcloud/upload'
import type { UserConfig } from '../types.ts'
+import type { View, ContentsWithRoot } from '@nextcloud/files'
-import { Folder, Node, type View, type ContentsWithRoot, join } from 'path'
+import { Folder, Node } from '@nextcloud/files'
+import { join, dirname } from 'path'
import { orderBy } from 'natural-orderby'
import { translate } from '@nextcloud/l10n'
+import { UploadPicker } from '@nextcloud/upload'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
@@ -79,6 +92,7 @@ import Vue from 'vue'
import { useFilesStore } from '../store/files.ts'
import { usePathsStore } from '../store/paths.ts'
import { useSelectionStore } from '../store/selection.ts'
+import { useUploaderStore } from '../store/uploader.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
@@ -97,6 +111,7 @@ export default Vue.extend({
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
+ UploadPicker,
},
mixins: [
@@ -107,12 +122,14 @@ export default Vue.extend({
const filesStore = useFilesStore()
const pathsStore = usePathsStore()
const selectionStore = useSelectionStore()
+ const uploaderStore = useUploaderStore()
const userConfigStore = useUserConfigStore()
const viewConfigStore = useViewConfigStore()
return {
filesStore,
pathsStore,
selectionStore,
+ uploaderStore,
userConfigStore,
viewConfigStore,
}
@@ -273,6 +290,7 @@ export default Vue.extend({
this.filesStore.updateNodes(contents)
// Define current directory children
+ // TODO: make it more official
folder._children = contents.map(node => node.fileid)
// If we're in the root dir, define the root
@@ -308,10 +326,28 @@ export default Vue.extend({
* @param {number} fileId the file id to get
* @return {Folder|File}
*/
- getNode(fileId) {
+ getNode(fileId) {
return this.filesStore.getNode(fileId)
},
+ /**
+ * The upload manager have finished handling the queue
+ * @param {Upload} upload the uploaded data
+ */
+ onUpload(upload: Upload) {
+ // Let's only refresh the current Folder
+ // Navigating to a different folder will refresh it anyway
+ const destinationSource = dirname(upload.source)
+ const needsRefresh = destinationSource === this.currentFolder?.source
+
+ // TODO: fetch uploaded files data only
+ // Use parseInt(upload.response?.headers?.['oc-fileid']) to get the fileid
+ if (needsRefresh) {
+ // fetchContent will cancel the previous ongoing promise
+ this.fetchContent()
+ }
+ },
+
t: translate,
},
})