From ce87be06165ca1a20fa177382ef35d5c2059757d Mon Sep 17 00:00:00 2001 From: Jacob Noah Date: Mon, 21 Aug 2023 14:55:57 +0200 Subject: [PATCH 1/7] Backup app, dependencies migration to web-pkg --- packages/web-app-backups/l10n/.tx/config | 10 + .../web-app-backups/l10n/translations.json | 1 + packages/web-app-backups/package.json | 18 + .../src/components/BackupsSection.vue | 696 +++++++++ packages/web-app-backups/src/index.js | 105 ++ .../web-app-backups/src/views/Backups.vue | 74 + packages/web-pkg/package.json | 1 + .../web-pkg/src/components/AppBar/AppBar.vue | 342 +++++ .../components/AppBar/SharesNavigation.vue | 140 ++ .../src/components/AppBar/SidebarToggle.vue | 55 + .../components/FilesList/ContextActions.vue | 188 +++ .../components/FilesList/ResourceTable.vue | 1279 +++++++++++++++++ .../src/components/FilesViewWrapper.vue | 41 + .../src/composables/actions/files/index.ts | 22 + .../actions/files/useFileActions.ts | 344 +++++ .../files/useFileActionsAcceptShare.ts | 119 ++ .../actions/files/useFileActionsCopy.ts | 101 ++ .../files/useFileActionsCreateNewFile.ts | 253 ++++ .../files/useFileActionsCreateNewFolder.ts | 142 ++ .../files/useFileActionsCreateQuicklink.ts | 58 + .../useFileActionsCreateSpaceFromResource.ts | 128 ++ .../files/useFileActionsDeclineShare.ts | 123 ++ .../actions/files/useFileActionsDelete.ts | 137 ++ .../files/useFileActionsDownloadArchive.ts | 166 +++ .../files/useFileActionsDownloadFile.ts | 62 + .../files/useFileActionsEmptyTrashBin.ts | 110 ++ .../actions/files/useFileActionsFavorite.ts | 71 + .../actions/files/useFileActionsMove.ts | 69 + .../actions/files/useFileActionsNavigate.ts | 114 ++ .../actions/files/useFileActionsPaste.ts | 118 ++ .../actions/files/useFileActionsRename.ts | 253 ++++ .../actions/files/useFileActionsRestore.ts | 310 ++++ .../actions/files/useFileActionsSetImage.ts | 119 ++ .../files/useFileActionsShowActions.ts | 48 + .../files/useFileActionsShowDetails.ts | 48 + .../files/useFileActionsShowEditTags.ts | 51 + .../actions/files/useFileActionsShowShares.ts | 58 + .../helpers/useFileActionsDeleteResources.ts | 259 ++++ .../actions/helpers/useIsFilesAppActive.ts | 14 + .../src/composables/filesList/index.ts | 1 + .../filesList/useResourceRouteResolver.ts | 81 ++ packages/web-pkg/src/composables/index.ts | 1 + .../resourcesViewDefaults/index.ts | 1 + .../useResourcesViewDefaults.ts | 119 ++ .../web-pkg/src/composables/router/index.ts | 1 + .../composables/router/useActiveLocation.ts | 27 + .../web-pkg/src/composables/scrollTo/index.ts | 1 + .../src/composables/scrollTo/useScrollTo.ts | 67 + .../src/composables/selection/index.ts | 1 + .../selection/useSelectedResources.ts | 70 + .../web-pkg/src/composables/spaces/index.ts | 1 + .../src/composables/spaces/useCreateSpace.ts | 44 + packages/web-pkg/src/helpers/breadcrumbs.ts | 44 + .../web-pkg/src/helpers/clipboardActions.ts | 4 + .../web-pkg/src/helpers/folderLink/index.ts | 1 + .../web-pkg/src/helpers/folderLink/types.ts | 7 + packages/web-pkg/src/helpers/permissions.ts | 14 + .../src/helpers/resource/ancestorMetaData.ts | 9 + .../conflictHandling/conflictDialog.ts | 155 ++ .../conflictHandling/conflictUtils.ts | 16 + .../resource/conflictHandling/index.ts | 3 + .../resource/conflictHandling/types.ts | 10 + .../web-pkg/src/helpers/resource/index.ts | 3 + .../src/helpers/resource/sameResource.ts | 8 + packages/web-pkg/src/helpers/resources.ts | 398 +++++ packages/web-pkg/src/helpers/share/index.ts | 2 + packages/web-pkg/src/helpers/share/link.ts | 93 ++ .../src/helpers/share/sharedAncestorRoute.ts | 45 + .../src/helpers/share/triggerShareAction.ts | 44 + .../web-pkg/src/helpers/statusIndicators.ts | 88 ++ packages/web-pkg/src/helpers/store.ts | 13 + packages/web-pkg/src/helpers/ui/filesList.ts | 12 + packages/web-pkg/src/helpers/ui/index.ts | 3 + .../web-pkg/src/helpers/ui/resourceTable.ts | 62 + .../web-pkg/src/helpers/ui/resourceTiles.ts | 76 + packages/web-pkg/src/quickActions.ts | 106 ++ packages/web-pkg/src/router/common.ts | 50 + packages/web-pkg/src/router/deprecated.ts | 134 ++ packages/web-pkg/src/router/index.ts | 62 + packages/web-pkg/src/router/public.ts | 55 + packages/web-pkg/src/router/router.ts | 29 + packages/web-pkg/src/router/shares.ts | 61 + packages/web-pkg/src/router/spaces.ts | 46 + packages/web-pkg/src/router/trash.ts | 44 + packages/web-pkg/src/router/utils.ts | 101 ++ packages/web-pkg/src/services/folder.ts | 72 + packages/web-pkg/src/services/folder/index.ts | 7 + .../src/services/folder/loaderFavorites.ts | 47 + .../services/folder/loaderSharedViaLink.ts | 67 + .../src/services/folder/loaderSharedWithMe.ts | 63 + .../services/folder/loaderSharedWithOthers.ts | 71 + .../src/services/folder/loaderSpace.ts | 94 ++ .../src/services/folder/loaderTrashbin.ts | 45 + packages/web-pkg/src/services/folder/types.ts | 3 + pnpm-lock.yaml | 39 + 95 files changed, 8968 insertions(+) create mode 100644 packages/web-app-backups/l10n/.tx/config create mode 100644 packages/web-app-backups/l10n/translations.json create mode 100644 packages/web-app-backups/package.json create mode 100644 packages/web-app-backups/src/components/BackupsSection.vue create mode 100644 packages/web-app-backups/src/index.js create mode 100644 packages/web-app-backups/src/views/Backups.vue create mode 100644 packages/web-pkg/src/components/AppBar/AppBar.vue create mode 100644 packages/web-pkg/src/components/AppBar/SharesNavigation.vue create mode 100644 packages/web-pkg/src/components/AppBar/SidebarToggle.vue create mode 100644 packages/web-pkg/src/components/FilesList/ContextActions.vue create mode 100644 packages/web-pkg/src/components/FilesList/ResourceTable.vue create mode 100644 packages/web-pkg/src/components/FilesViewWrapper.vue create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActions.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsAcceptShare.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsCopy.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsCreateSpaceFromResource.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsDeclineShare.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsDelete.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsDownloadArchive.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsDownloadFile.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsEmptyTrashBin.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsFavorite.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsMove.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsNavigate.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsRename.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsSetImage.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsShowActions.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsShowDetails.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsShowShares.ts create mode 100644 packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts create mode 100644 packages/web-pkg/src/composables/actions/helpers/useIsFilesAppActive.ts create mode 100644 packages/web-pkg/src/composables/filesList/index.ts create mode 100644 packages/web-pkg/src/composables/filesList/useResourceRouteResolver.ts create mode 100644 packages/web-pkg/src/composables/resourcesViewDefaults/index.ts create mode 100644 packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts create mode 100644 packages/web-pkg/src/composables/router/useActiveLocation.ts create mode 100644 packages/web-pkg/src/composables/scrollTo/index.ts create mode 100644 packages/web-pkg/src/composables/scrollTo/useScrollTo.ts create mode 100644 packages/web-pkg/src/composables/selection/index.ts create mode 100644 packages/web-pkg/src/composables/selection/useSelectedResources.ts create mode 100644 packages/web-pkg/src/composables/spaces/useCreateSpace.ts create mode 100644 packages/web-pkg/src/helpers/breadcrumbs.ts create mode 100644 packages/web-pkg/src/helpers/clipboardActions.ts create mode 100644 packages/web-pkg/src/helpers/folderLink/index.ts create mode 100644 packages/web-pkg/src/helpers/folderLink/types.ts create mode 100644 packages/web-pkg/src/helpers/permissions.ts create mode 100644 packages/web-pkg/src/helpers/resource/ancestorMetaData.ts create mode 100644 packages/web-pkg/src/helpers/resource/conflictHandling/conflictDialog.ts create mode 100644 packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts create mode 100644 packages/web-pkg/src/helpers/resource/conflictHandling/index.ts create mode 100644 packages/web-pkg/src/helpers/resource/conflictHandling/types.ts create mode 100644 packages/web-pkg/src/helpers/resource/index.ts create mode 100644 packages/web-pkg/src/helpers/resource/sameResource.ts create mode 100644 packages/web-pkg/src/helpers/resources.ts create mode 100644 packages/web-pkg/src/helpers/share/index.ts create mode 100644 packages/web-pkg/src/helpers/share/link.ts create mode 100644 packages/web-pkg/src/helpers/share/sharedAncestorRoute.ts create mode 100644 packages/web-pkg/src/helpers/share/triggerShareAction.ts create mode 100644 packages/web-pkg/src/helpers/statusIndicators.ts create mode 100644 packages/web-pkg/src/helpers/store.ts create mode 100644 packages/web-pkg/src/helpers/ui/filesList.ts create mode 100644 packages/web-pkg/src/helpers/ui/index.ts create mode 100644 packages/web-pkg/src/helpers/ui/resourceTable.ts create mode 100644 packages/web-pkg/src/helpers/ui/resourceTiles.ts create mode 100644 packages/web-pkg/src/quickActions.ts create mode 100644 packages/web-pkg/src/router/common.ts create mode 100644 packages/web-pkg/src/router/deprecated.ts create mode 100644 packages/web-pkg/src/router/index.ts create mode 100644 packages/web-pkg/src/router/public.ts create mode 100644 packages/web-pkg/src/router/router.ts create mode 100644 packages/web-pkg/src/router/shares.ts create mode 100644 packages/web-pkg/src/router/spaces.ts create mode 100644 packages/web-pkg/src/router/trash.ts create mode 100644 packages/web-pkg/src/router/utils.ts create mode 100644 packages/web-pkg/src/services/folder.ts create mode 100644 packages/web-pkg/src/services/folder/index.ts create mode 100644 packages/web-pkg/src/services/folder/loaderFavorites.ts create mode 100644 packages/web-pkg/src/services/folder/loaderSharedViaLink.ts create mode 100644 packages/web-pkg/src/services/folder/loaderSharedWithMe.ts create mode 100644 packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts create mode 100644 packages/web-pkg/src/services/folder/loaderSpace.ts create mode 100644 packages/web-pkg/src/services/folder/loaderTrashbin.ts create mode 100644 packages/web-pkg/src/services/folder/types.ts diff --git a/packages/web-app-backups/l10n/.tx/config b/packages/web-app-backups/l10n/.tx/config new file mode 100644 index 00000000000..216f05a61e3 --- /dev/null +++ b/packages/web-app-backups/l10n/.tx/config @@ -0,0 +1,10 @@ +[main] +host = https://www.transifex.com + +[owncloud-web.text-editor] +file_filter = locale//LC_MESSAGES/app.po +minimum_perc = 0 +source_file = template.pot +source_lang = en +type = PO + diff --git a/packages/web-app-backups/l10n/translations.json b/packages/web-app-backups/l10n/translations.json new file mode 100644 index 00000000000..55d7afd216b --- /dev/null +++ b/packages/web-app-backups/l10n/translations.json @@ -0,0 +1 @@ +{"cs":{"Save":"Uložit"},"de":{"An error occurred":"Ein Fehler ist aufgetreten","Don't Save":"Verwerfen","Error when contacting the server":"Fehler beim Kontaktieren des Servers","extensionItem":"Erweiterungselement","JavaScript file":"JavaScript-Datei","JSON file":"JSON-Datei","Loading editor content":"Lade Editor Inhalt","Markdown file":"Markdown-Datei","msg":"Nachricht","PHP file":"PHP-Datei","Plain text file":"Text-Datei","Python file":"Python-Datei","Save":"Speichern","Text Editor":"Texteditor","This file was updated outside this window. Please refresh the page (all changes will be lost).":"Die Datei wurde außerhalb des Fensters aktualisiert. Bitte die Seite neu laden (alle Änderungen gehen verloren).","Unsaved changes":"Ungespeicherte Änderungen","XML file":"XML-Datei","YAML file":"YAML-Datei","You're not authorized to save this file":"Keine ausreichenden Berechtigungen für das Speichern der Datei ","Your changes were not saved. Do you want to save them?":"Ungesicherte Änderungen. Möchtest Du sie speichern? "},"es":{"Loading editor content":"Cargando el editor de contenido","msg":"msg","Save":"Guardar"},"fr":{"An error occurred":"Une erreur est survenue","Don't Save":"Ne pas enregistrer","Error when contacting the server":"Erreur de communication avec le serveur","JavaScript file":"Fichier JavaScript","JSON file":"Fichier JSON","Loading editor content":"Chargement du contenu de l'éditeur","Markdown file":"Fichier Markdown","msg":"msg","PHP file":"Fichier PHP","Plain text file":"Fichier texte brut","Python file":"Fichier Python","Save":"Sauvegarder","Text Editor":"Éditeur de texte","This file was updated outside this window. Please refresh the page (all changes will be lost).":"Ce fichier a été modifié en dehors de cette fenêtre. Veuillez rafraîchir la page (tous les changements seront perdus).","Unsaved changes":"Modifications non enregistrées","XML file":"Fichier XML","YAML file":"Fichier YAML","You're not authorized to save this file":"Vous n'êtes pas autorisé à enregistrer ce fichier","Your changes were not saved. Do you want to save them?":"Les modifications n'ont pas été enregistrées, souhaitez-vous les enregistrer ?"},"gl":{"msg":"msx","Save":"Gardar"},"it":{}} \ No newline at end of file diff --git a/packages/web-app-backups/package.json b/packages/web-app-backups/package.json new file mode 100644 index 00000000000..acafad795df --- /dev/null +++ b/packages/web-app-backups/package.json @@ -0,0 +1,18 @@ +{ + "name": "backups", + "version": "0.0.0", + "description": "CERNBox backups management", + "license": "AGPL-3.0", + "peerDependencies": { + "axios": "^0.27.2", + "fuse.js": "^6.5.3", + "lodash-es": "4.17.21", + "uuid": "^9.0.0", + "vue-concurrency": "4.0.0", + "vuex": "4.1.0", + "web-pkg": "npm:@ownclouders/web-pkg", + "web-client": "npm:@ownclouders/web-client" + } +} + + diff --git a/packages/web-app-backups/src/components/BackupsSection.vue b/packages/web-app-backups/src/components/BackupsSection.vue new file mode 100644 index 00000000000..cb601191a13 --- /dev/null +++ b/packages/web-app-backups/src/components/BackupsSection.vue @@ -0,0 +1,696 @@ + + + + + diff --git a/packages/web-app-backups/src/index.js b/packages/web-app-backups/src/index.js new file mode 100644 index 00000000000..b9675baabc6 --- /dev/null +++ b/packages/web-app-backups/src/index.js @@ -0,0 +1,105 @@ +import translations from '../l10n/translations' +import Backups from './views/Backups.vue' + + +const appInfo = { + name: 'Backups', + id: 'backups', + icon: 'arrow-go-back', + isFileEditor: false +} + +const routes = [ + { + path: '/', + redirect: () => { + return { name: 'backups-restore-jobs' } + } + }, + { + path: '/my-backups', + name: 'backups', + component: Backups, + meta: { + title: 'My backups' + }, + children: [ + { + path: ':item*', + name: 'backups-me', + component: Backups, + meta: { + patchCleanPath: true, + title: 'Backup' + } + } + ] + }, + { + path: '/project-backups', + name: 'backup-projects', + component: Backups, + meta: { + title: 'Projects backups' + }, + children: [ + { + path: ':item*', + name: 'backups-projects', + component: Backups, + meta: { + patchCleanPath: true, + title: 'Backup' + } + } + ] + }, + { + path: '/restore-jobs', + name: 'backups-restore-jobs', + component: Backups, + meta: { + title: 'Restore jobs' + } + } +] + +const navItems = [ + { + name: 'Restore jobs', + icon: 'server', + route: { + path: `/${appInfo.id}/restore-jobs?` + }, + enabled: () => { + return true + } + }, + { + name: 'My backups', + icon: 'arrow-go-back', + route: { + path: `/${appInfo.id}/my-backups?` + }, + enabled: () => { + return true + } + }, + { + name: 'Project backups', + icon: 'arrow-go-back', + route: { + path: `/${appInfo.id}/project-backups?` + }, + enabled: () => { + return true + } + } +] + +export default { + appInfo, + routes, + translations, + navItems +} diff --git a/packages/web-app-backups/src/views/Backups.vue b/packages/web-app-backups/src/views/Backups.vue new file mode 100644 index 00000000000..1f568c4c9b8 --- /dev/null +++ b/packages/web-app-backups/src/views/Backups.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 1d61acb65eb..18cfc771b61 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -26,6 +26,7 @@ "luxon": "^2.4.0", "mark.js": "^8.11.1", "pinia": "^2.1.3", + "p-queue": "^6.6.2", "qs": "^6.10.3", "semver": "^7.3.8", "uuid": "^9.0.0", diff --git a/packages/web-pkg/src/components/AppBar/AppBar.vue b/packages/web-pkg/src/components/AppBar/AppBar.vue new file mode 100644 index 00000000000..9f82a465fb5 --- /dev/null +++ b/packages/web-pkg/src/components/AppBar/AppBar.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/packages/web-pkg/src/components/AppBar/SharesNavigation.vue b/packages/web-pkg/src/components/AppBar/SharesNavigation.vue new file mode 100644 index 00000000000..e800f6b1152 --- /dev/null +++ b/packages/web-pkg/src/components/AppBar/SharesNavigation.vue @@ -0,0 +1,140 @@ + + + + diff --git a/packages/web-pkg/src/components/AppBar/SidebarToggle.vue b/packages/web-pkg/src/components/AppBar/SidebarToggle.vue new file mode 100644 index 00000000000..00aa92bb0d1 --- /dev/null +++ b/packages/web-pkg/src/components/AppBar/SidebarToggle.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/web-pkg/src/components/FilesList/ContextActions.vue b/packages/web-pkg/src/components/FilesList/ContextActions.vue new file mode 100644 index 00000000000..2cba8d3fc05 --- /dev/null +++ b/packages/web-pkg/src/components/FilesList/ContextActions.vue @@ -0,0 +1,188 @@ + + + diff --git a/packages/web-pkg/src/components/FilesList/ResourceTable.vue b/packages/web-pkg/src/components/FilesList/ResourceTable.vue new file mode 100644 index 00000000000..d3e82a5a62a --- /dev/null +++ b/packages/web-pkg/src/components/FilesList/ResourceTable.vue @@ -0,0 +1,1279 @@ + + + + diff --git a/packages/web-pkg/src/components/FilesViewWrapper.vue b/packages/web-pkg/src/components/FilesViewWrapper.vue new file mode 100644 index 00000000000..ffb6b7ce7c7 --- /dev/null +++ b/packages/web-pkg/src/components/FilesViewWrapper.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/packages/web-pkg/src/composables/actions/files/index.ts b/packages/web-pkg/src/composables/actions/files/index.ts index c934caf4c3a..afb401de7e5 100644 --- a/packages/web-pkg/src/composables/actions/files/index.ts +++ b/packages/web-pkg/src/composables/actions/files/index.ts @@ -1 +1,23 @@ export * from './useFileActionsSetReadme' +export * from './useFileActionsAcceptShare' +export * from './useFileActionsCopy' +export * from './useFileActionsCreateQuicklink' +export * from './useFileActionsDeclineShare' +export * from './useFileActionsDelete' +export * from './useFileActionsDownloadArchive' +export * from './useFileActionsDownloadFile' +export * from './useFileActionsEmptyTrashBin' +export * from './useFileActionsFavorite' +export * from './useFileActionsMove' +export * from './useFileActionsNavigate' +export * from './useFileActionsPaste' +export * from './useFileActionsRename' +export * from './useFileActionsRestore' +export * from './useFileActionsSetImage' +export * from './useFileActionsShowActions' +export * from './useFileActionsShowDetails' +export * from './useFileActionsShowEditTags' +export * from './useFileActionsShowShares' +export * from './useFileActionsCreateSpaceFromResource' +export * from './useFileActionsCreateNewFolder' +export * from './useFileActionsCreateNewFile' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActions.ts new file mode 100644 index 00000000000..1dbc9e8da85 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActions.ts @@ -0,0 +1,344 @@ +import kebabCase from 'lodash-es/kebabCase' +import { Store } from 'vuex' +import { ShareStatus } from 'web-client/src/helpers/share' +import { routeToContextQuery } from 'web-pkg/src/composables/appDefaults' +import { configurationManager } from 'web-pkg/src/configuration' + +import { isLocationSharesActive, isLocationTrashActive } from '../../../router' +import { computed, unref } from 'vue' +import { useCapabilityFilesAppProviders, useRouter, useStore } from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { + Action, + FileAction, + FileActionOptions, + useIsSearchActive, + useWindowOpen +} from 'web-pkg/src/composables/actions' + +import { + useFileActionsAcceptShare, + useFileActionsCopy, + useFileActionsDeclineShare, + useFileActionsDelete, + useFileActionsDownloadArchive, + useFileActionsDownloadFile, + useFileActionsFavorite, + useFileActionsMove, + useFileActionsNavigate, + useFileActionsRename, + useFileActionsRestore, + useFileActionsShowEditTags, + useFileActionsCreateSpaceFromResource +} from './index' + +export const EDITOR_MODE_EDIT = 'edit' +export const EDITOR_MODE_CREATE = 'create' + +export interface GetFileActionsOptions extends FileActionOptions { + omitSystemActions?: boolean +} + +export const useFileActions = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext, interpolate: $gettextInterpolate } = useGettext() + const isSearchActive = useIsSearchActive() + const appProviders = useCapabilityFilesAppProviders() + const appProvidersEnabled = computed(() => { + return !!unref(appProviders).find((appProvider) => appProvider.enabled) + }) + + const { openUrl } = useWindowOpen() + + const { actions: acceptShareActions } = useFileActionsAcceptShare({ store }) + const { actions: copyActions } = useFileActionsCopy({ store }) + const { actions: deleteActions } = useFileActionsDelete({ store }) + const { actions: declineShareActions } = useFileActionsDeclineShare({ store }) + const { actions: downloadArchiveActions } = useFileActionsDownloadArchive({ store }) + const { actions: downloadFileActions } = useFileActionsDownloadFile() + const { actions: favoriteActions } = useFileActionsFavorite({ store }) + const { actions: moveActions } = useFileActionsMove({ store }) + const { actions: navigateActions } = useFileActionsNavigate({ store }) + const { actions: renameActions } = useFileActionsRename({ store }) + const { actions: restoreActions } = useFileActionsRestore({ store }) + const { actions: showEditTagsActions } = useFileActionsShowEditTags({ store }) + const { actions: createSpaceFromResource } = useFileActionsCreateSpaceFromResource({ store }) + + const systemActions = computed((): Action[] => [ + ...unref(downloadArchiveActions), + ...unref(downloadFileActions), + ...unref(deleteActions), + ...unref(moveActions), + ...unref(copyActions), + ...unref(renameActions), + ...unref(createSpaceFromResource), + ...unref(showEditTagsActions), + ...unref(restoreActions), + ...unref(acceptShareActions), + ...unref(declineShareActions), + ...unref(favoriteActions), + ...unref(navigateActions) + ]) + + const editorActions = computed(() => { + const apps = store.state.apps + return (apps.fileEditors as any[]) + .map((editor): FileAction => { + return { + name: `editor-${editor.id}`, + label: () => { + if (editor.label) { + return $gettext(editor.label) + } + const translated = $gettext('Open in %{app}') + return $gettextInterpolate(translated, { app: apps.meta[editor.app].name }, true) + }, + icon: apps.meta[editor.app].icon, + ...(apps.meta[editor.app].iconFillType && { + iconFillType: apps.meta[editor.app].iconFillType + }), + img: apps.meta[editor.app].img, + handler: (options) => + openEditor( + editor, + options.space.getDriveAliasAndItem(options.resources[0]), + options.resources[0].webDavPath, + options.resources[0].fileId, + EDITOR_MODE_EDIT, + options.space.shareId + ), + isEnabled: ({ resources }) => { + if (resources.length !== 1) { + return false + } + + if ( + !unref(isSearchActive) && + (isLocationTrashActive(router, 'files-trash-generic') || + (isLocationSharesActive(router, 'files-shares-with-me') && + resources[0].status !== ShareStatus.accepted)) + ) { + return false + } + + if (resources[0].extension && editor.extension) { + return resources[0].extension.toLowerCase() === editor.extension.toLowerCase() + } + + if (resources[0].mimeType && editor.mimeType) { + return ( + resources[0].mimeType.toLowerCase() === editor.mimeType.toLowerCase() || + resources[0].mimeType.split('/')[0].toLowerCase() === editor.mimeType.toLowerCase() + ) + } + + return false + }, + hasPriority: editor.hasPriority, + componentType: 'button', + class: `oc-files-actions-${kebabCase(apps.meta[editor.app].name).toLowerCase()}-trigger` + } + }) + .sort((first, second) => { + // Ensure default are listed first + if (second.hasPriority !== first.hasPriority && second.hasPriority) { + return 1 + } + return 0 + }) + }) + + const routeOptsHelper = (app, driveAliasAndItem: string, filePath, fileId, mode, shareId) => { + return { + name: app.routeName || app.app, + params: { + driveAliasAndItem, + filePath, + fileId, + mode + }, + query: { + ...(shareId && { shareId }), + ...(fileId && configurationManager.options.routing.idBased && { fileId }), + ...routeToContextQuery(unref(router.currentRoute)) + } + } + } + + const openEditor = ( + editor, + driveAliasAndItem: string, + filePath, + fileId, + mode, + shareId = undefined + ) => { + const configuration = store.getters['configuration'] + + if (editor.handler) { + return editor.handler({ + config: configuration, + extensionConfig: editor.config, + driveAliasAndItem, + filePath, + fileId, + mode, + ...(shareId && { shareId }) + }) + } + + const routeOpts = routeOptsHelper(editor, driveAliasAndItem, filePath, fileId, mode, shareId) + + if (configuration.options.openAppsInTab) { + const path = router.resolve(routeOpts).href + const target = `${editor.routeName}-${filePath}` + + openUrl(path, target, true) + return + } + + router.push(routeOpts) + } + + // TODO: Make user-configurable what is a defaultAction for a filetype/mimetype + // returns the _first_ action from actions array which we now construct from + // available mime-types coming from the app-provider and existing actions + const triggerDefaultAction = (options: FileActionOptions) => { + const action = getDefaultAction(options) + action.handler({ ...options }) + } + + const triggerAction = (name: string, options: FileActionOptions) => { + const action = getAllAvailableActions(options).filter((action) => action.name === name)[0] + if (!action) { + throw new Error(`Action not found: '${name}'`) + } + + action.handler(options) + } + + const getDefaultEditorAction = (options: FileActionOptions): Action | null => { + return getDefaultAction({ ...options, omitSystemActions: true }) + } + + const getDefaultAction = (options: GetFileActionsOptions): Action | null => { + const filterCallback = (action) => + action.isEnabled({ + ...options, + parent: store.getters['Files/currentFolder'] + }) + + // first priority: handlers from config + const enabledEditorActions = unref(editorActions).filter(filterCallback) + + // second priority: `/app/open` endpoint of app provider if available + // FIXME: files app should not know anything about the `external apps` app + const externalAppsActions = loadExternalAppActions(options).filter(filterCallback) + + // prioritize apps that have hasPriority set + const appActions = [...enabledEditorActions, ...externalAppsActions].sort( + (a, b) => Number(b.hasPriority) - Number(a.hasPriority) + ) + + if (appActions.length) { + return appActions[0] + } + + // fallback: system actions + return options.omitSystemActions ? null : unref(systemActions).filter(filterCallback)[0] + } + + const getAllAvailableActions = (options: FileActionOptions) => { + return [ + ...unref(editorActions), + ...loadExternalAppActions(options), + ...unref(systemActions) + ].filter((action) => { + return action.isEnabled(options) + }) + } + + // returns an array of available external Apps + // to open a resource with a specific mimeType + // FIXME: filesApp should not know anything about any other app, dont cross the line!!! BAD + const loadExternalAppActions = (options: FileActionOptions): Action[] => { + if (isLocationTrashActive(router, 'files-trash-generic')) { + return [] + } + + // we don't support external apps as batch action as of now + if (options.resources.length !== 1) { + return [] + } + + const resource = options.resources[0] + const { mimeType, webDavPath, fileId } = resource + const driveAliasAndItem = options.space?.getDriveAliasAndItem(resource) + if (!driveAliasAndItem) { + return [] + } + + const mimeTypes = store.getters['External/mimeTypes'] || [] + if (mimeType === undefined || !unref(appProvidersEnabled) || !mimeTypes.length) { + return [] + } + + const filteredMimeTypes = mimeTypes.find((t) => t.mime_type === mimeType) + if (filteredMimeTypes === undefined) { + return [] + } + const { app_providers: appProviders = [], default_application: defaultApplication } = + filteredMimeTypes + + return appProviders.map((app): FileAction => { + const label = $gettext('Open in %{ appName }') + return { + name: app.name, + icon: app.icon, + img: app.img, + componentType: 'button', + class: `oc-files-actions-${app.name}-trigger`, + isEnabled: () => true, + hasPriority: defaultApplication === app.name, + handler: () => + openExternalApp(app.name, driveAliasAndItem, webDavPath, fileId, options.space.shareId), + label: () => $gettextInterpolate(label, { appName: app.name }) + } + }) + } + + const openExternalApp = (app, driveAliasAndItem: string, filePath, fileId, shareId) => { + const routeOpts = routeOptsHelper( + { + routeName: 'external-apps' + }, + driveAliasAndItem, + filePath, + undefined, + undefined, + shareId + ) + + routeOpts.query = { + app, + fileId, + ...routeOpts.query + } as any + + // TODO: Let users configure whether to open in same/new tab (`_blank` vs `_self`) + openUrl(router.resolve(routeOpts).href, '_blank') + } + + return { + editorActions, + systemActions, + getDefaultAction, + getDefaultEditorAction, + getAllAvailableActions, + loadExternalAppActions, + openEditor, + triggerAction, + triggerDefaultAction + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsAcceptShare.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsAcceptShare.ts new file mode 100644 index 00000000000..545c7b33f07 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsAcceptShare.ts @@ -0,0 +1,119 @@ +import { triggerShareAction } from '../../../helpers/share/triggerShareAction' + +import { Store } from 'vuex' +import PQueue from 'p-queue' +import { ShareStatus } from 'web-client/src/helpers/share' +import { isLocationSharesActive, isLocationSpacesActive } from '../../../router' +import { + useCapabilityFilesSharingResharing, + useCapabilityShareJailEnabled, + useClientService, + useLoadingService, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions/types' + +export const useFileActionsAcceptShare = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $ngettext } = useGettext() + + const hasResharing = useCapabilityFilesSharingResharing() + const hasShareJail = useCapabilityShareJailEnabled() + const clientService = useClientService() + const loadingService = useLoadingService() + + const handler = async ({ resources }: FileActionOptions) => { + const errors = [] + const triggerPromises = [] + const triggerQueue = new PQueue({ concurrency: 4 }) + resources.forEach((resource) => { + triggerPromises.push( + triggerQueue.add(async () => { + try { + const share = await triggerShareAction( + resource, + ShareStatus.accepted, + unref(hasResharing), + unref(hasShareJail), + clientService.owncloudSdk + ) + if (share) { + store.commit('Files/UPDATE_RESOURCE', share) + } + } catch (error) { + console.error(error) + errors.push(error) + } + }) + ) + }) + await Promise.all(triggerPromises) + + if (errors.length === 0) { + store.dispatch('Files/resetFileSelection') + + if (isLocationSpacesActive(router, 'files-spaces-generic')) { + store.dispatch('showMessage', { + title: $ngettext( + 'The selected share was accepted successfully', + 'The selected shares were accepted successfully', + resources.length + ) + }) + } + + return + } + + store.dispatch('showErrorMessage', { + title: $ngettext( + 'Failed to accept the selected share.', + 'Failed to accept selected shares.', + resources.length + ), + errors + }) + } + + const actions = computed((): FileAction[] => [ + { + name: 'accept-share', + icon: 'check', + handler: (args) => loadingService.addTask(() => handler(args)), + label: ({ resources }) => $ngettext('Accept share', 'Accept shares', resources.length), + isEnabled: ({ space, resources }) => { + if ( + !isLocationSharesActive(router, 'files-shares-with-me') && + !isLocationSpacesActive(router, 'files-spaces-generic') + ) { + return false + } + if (resources.length === 0) { + return false + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + (unref(space)?.driveType !== 'share' || resources.length > 1 || resources[0].path !== '/') + ) { + return false + } + + const acceptDisabled = resources.some((resource) => { + return resource.status === ShareStatus.accepted + }) + return !acceptDisabled + }, + componentType: 'button', + class: 'oc-files-actions-accept-share-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCopy.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCopy.ts new file mode 100644 index 00000000000..d05b4b370fe --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCopy.ts @@ -0,0 +1,101 @@ +import { + isLocationCommonActive, + isLocationPublicActive, + isLocationSpacesActive +} from '../../../router' +import { Store } from 'vuex' +import { computed, unref } from 'vue' +import { useRouter, useStore } from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { isProjectSpaceResource } from 'web-client/src/helpers' + +export const useFileActionsCopy = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + + const language = useGettext() + const { $pgettext } = language + + const isMacOs = computed(() => { + return window.navigator.platform.match('Mac') + }) + + const copyShortcutString = computed(() => { + if (unref(isMacOs)) { + return $pgettext('Keyboard shortcut for macOS for copying files', '⌘ + C') + } + return $pgettext('Keyboard shortcut for non-macOS systems for copying files', 'Ctrl + C') + }) + + const handler = ({ space, resources }: FileActionOptions) => { + if (isLocationCommonActive(router, 'files-common-search')) { + resources = resources.filter((r) => !isProjectSpaceResource(r)) + } + + store.dispatch('Files/copySelectedFiles', { ...language, space, resources }) + } + + const actions = computed((): FileAction[] => { + return [ + { + name: 'copy', + icon: 'file-copy-2', + handler, + shortcut: unref(copyShortcutString), + label: ({ resources }) => { + const copyLabel = $pgettext( + 'Action in the files list row to initiate copying resources', + 'Copy' + ) + + if (isLocationCommonActive(router, 'files-common-search') && resources.length > 1) { + const copyableResourcesCount = resources.filter( + (r) => !isProjectSpaceResource(r) + ).length + return `${copyLabel} (${copyableResourcesCount.toString()})` + } + + return copyLabel + }, + isEnabled: ({ resources }) => { + if ( + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationPublicActive(router, 'files-public-link') && + !isLocationCommonActive(router, 'files-common-favorites') && + !isLocationCommonActive(router, 'files-common-search') + ) { + return false + } + if (isLocationSpacesActive(router, 'files-spaces-projects')) { + return false + } + if (resources.length === 0) { + return false + } + + if (isLocationPublicActive(router, 'files-public-link')) { + return store.getters['Files/currentFolder'].canCreate() + } + + if ( + isLocationCommonActive(router, 'files-common-search') && + resources.every((r) => isProjectSpaceResource(r)) + ) { + return false + } + + // copy can't be restricted in authenticated context, because + // a user always has their home dir with write access + return true + }, + componentType: 'button', + class: 'oc-files-actions-copy-trigger' + } + ] + }) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts new file mode 100644 index 00000000000..1a5da3df9e0 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts @@ -0,0 +1,253 @@ +import { Resource, SpaceResource, extractNameWithoutExtension } from 'web-client/src/helpers' +import { Store } from 'vuex' +import { computed, Ref, unref } from 'vue' +import { useClientService, useRequest, useRouter, useStore } from 'web-pkg/src/composables' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { useGettext } from 'vue3-gettext' +import { resolveFileNameDuplicate } from 'web-pkg/src/helpers/resource' +import { join } from 'path' +import { WebDAV } from 'web-client/src/webdav' +import { isLocationSpacesActive } from 'web-pkg/src/router' +import { getIndicators } from 'web-pkg/src/helpers/statusIndicators' +import { EDITOR_MODE_CREATE, useFileActions } from './useFileActions' +import { urlJoin } from 'web-client/src/utils' +import { configurationManager } from 'web-pkg/src' +import { stringify } from 'qs' + +export const useFileActionsCreateNewFile = ({ + store, + space, + newFileHandlers, + mimetypesAllowedForCreation +}: { + store?: Store + space?: SpaceResource + newFileHandlers?: Ref // FIXME: type? + mimetypesAllowedForCreation?: Ref // FIXME: type? +} = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext } = useGettext() + const { makeRequest } = useRequest() + + const { openEditor, triggerDefaultAction } = useFileActions() + const clientService = useClientService() + const currentFolder = computed((): Resource => store.getters['Files/currentFolder']) + const files = computed((): Array => store.getters['Files/files']) + const ancestorMetaData = computed(() => store.getters['Files/ancestorMetaData']) + const areFileExtensionsShown = computed((): boolean => store.state.Files.areFileExtensionsShown) + + const capabilities = computed(() => store.getters['capabilities']) + + const checkNewFileName = (fileName) => { + if (fileName === '') { + return $gettext('File name cannot be empty') + } + + if (/[/]/.test(fileName)) { + return $gettext('File name cannot contain "/"') + } + + if (fileName === '.') { + return $gettext('File name cannot be equal to "."') + } + + if (fileName === '..') { + return $gettext('File name cannot be equal to ".."') + } + + if (/\s+$/.test(fileName)) { + return $gettext('File name cannot end with whitespace') + } + + const exists = unref(files).find((file) => file.name === fileName) + + if (exists) { + return $gettext('%{name} already exists', { name: fileName }, true) + } + + return null + } + + const addAppProviderFileFunc = async (fileName) => { + // FIXME: this belongs in web-app-external, but the app provider handles file creation differently than other editor extensions. Needs more refactoring. + if (fileName === '') { + return + } + try { + const baseUrl = urlJoin( + configurationManager.serverUrl, + unref(capabilities).files.app_providers[0].new_url + ) + const query = stringify({ + parent_container_id: unref(currentFolder).fileId, + filename: fileName + }) + const url = `${baseUrl}?${query}` + const response = await makeRequest('POST', url) + if (response.status !== 200) { + throw new Error(`An error has occurred: ${response.status}`) + } + const path = join(unref(currentFolder).path, fileName) || '' + const resource = await (clientService.webdav as WebDAV).getFileInfo(space, { + path + }) + if (unref(loadIndicatorsForNewFile)) { + resource.indicators = getIndicators({ resource, ancestorMetaData: unref(ancestorMetaData) }) + } + triggerDefaultAction({ space: space, resources: [resource] }) + store.commit('Files/UPSERT_RESOURCE', resource) + store.dispatch('hideModal') + store.dispatch('showMessage', { + title: $gettext('"%{fileName}" was created successfully', { fileName }) + }) + } catch (error) { + console.error(error) + store.dispatch('showErrorMessage', { + title: $gettext('Failed to create file'), + error + }) + } + } + + const loadIndicatorsForNewFile = computed(() => { + return isLocationSpacesActive(router, 'files-spaces-generic') && space.driveType !== 'share' + }) + + const addNewFile = async (fileName, openAction) => { + if (fileName === '') { + return + } + + try { + const path = join(unref(currentFolder).path, fileName) + const resource = await (clientService.webdav as WebDAV).putFileContents(space, { + path + }) + + if (loadIndicatorsForNewFile.value) { + resource.indicators = getIndicators({ resource, ancestorMetaData: unref(ancestorMetaData) }) + } + + store.commit('Files/UPSERT_RESOURCE', resource) + + if (openAction) { + openEditor( + openAction, + space.getDriveAliasAndItem(resource), + resource.webDavPath, + resource.fileId, + EDITOR_MODE_CREATE + ) + store.dispatch('hideModal') + + return + } + + store.dispatch('hideModal') + store.dispatch('showMessage', { + title: $gettext('"%{fileName}" was created successfully', { fileName }) + }) + } catch (error) { + console.error(error) + store.dispatch('showErrorMessage', { + title: $gettext('Failed to create file'), + error + }) + } + } + + const handler = ( + fileActionOptions: FileActionOptions, + extension: string, + openAction: any // FIXME: type? + ) => { + const checkInputValue = (value) => { + store.dispatch( + 'setModalInputErrorMessage', + checkNewFileName(areFileExtensionsShown.value ? value : `${value}.${extension}`) + ) + } + let defaultName = $gettext('New file') + `.${extension}` + + if (unref(files).some((f) => f.name === defaultName)) { + defaultName = resolveFileNameDuplicate(defaultName, extension, unref(files)) + } + + if (!areFileExtensionsShown.value) { + defaultName = extractNameWithoutExtension({ name: defaultName, extension } as any) + } + + const inputSelectionRange = !areFileExtensionsShown.value + ? null + : [0, defaultName.length - (extension.length + 1)] + + const modal = { + variation: 'passive', + title: $gettext('Create a new file'), + cancelText: $gettext('Cancel'), + confirmText: $gettext('Create'), + hasInput: true, + inputValue: defaultName, + inputLabel: $gettext('File name'), + inputError: checkNewFileName( + areFileExtensionsShown.value ? defaultName : `${defaultName}.${extension}` + ), + inputSelectionRange, + onCancel: () => store.dispatch('hideModal'), + onConfirm: !openAction + ? addAppProviderFileFunc + : (fileName) => { + if (!areFileExtensionsShown.value) { + fileName = `${fileName}.${extension}` + } + addNewFile(fileName, openAction) + }, + onInput: checkInputValue + } + + store.dispatch('createModal', modal) + } + + const actions = computed((): FileAction[] => { + const actions = [] + for (const newFileHandler of unref(newFileHandlers) || []) { + const openAction = newFileHandler.action + actions.push({ + name: 'create-new-file', + icon: 'add', + handler: (args) => handler(args, newFileHandler.ext, openAction), + label: () => newFileHandler.menuTitle($gettext), + isEnabled: () => { + return unref(currentFolder)?.canUpload({ user: store.getters.user }) + }, + componentType: 'button', + class: 'oc-files-actions-create-new-file', + ext: newFileHandler.ext + }) + } + for (const mimeType of unref(mimetypesAllowedForCreation) || []) { + const openAction = false + actions.push({ + name: 'create-new-file', + icon: 'add', + handler: (args) => handler(args, mimeType.ext, openAction), + label: () => mimeType.name, + isEnabled: () => { + return unref(currentFolder)?.canUpload({ user: store.getters.user }) + }, + componentType: 'button', + class: 'oc-files-actions-create-new-file', + ext: mimeType.ext + }) + } + + return actions + }) + + return { + actions, + checkNewFileName, + addNewFile + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts new file mode 100644 index 00000000000..3aab4c4fc31 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts @@ -0,0 +1,142 @@ +import { Resource, SpaceResource } from 'web-client/src/helpers' +import { Store } from 'vuex' +import { computed, nextTick, unref } from 'vue' +import { useClientService, useRouter, useStore } from 'web-pkg/src/composables' +import { FileAction } from 'web-pkg/src/composables/actions' +import { useGettext } from 'vue3-gettext' +import { resolveFileNameDuplicate } from 'web-pkg/src/helpers/resource' +import { join } from 'path' +import { WebDAV } from 'web-client/src/webdav' +import { isLocationSpacesActive } from 'web-pkg/src/router' +import { getIndicators } from 'web-pkg/src/helpers/statusIndicators' +import { useScrollTo } from '../../scrollTo/useScrollTo' + +export const useFileActionsCreateNewFolder = ({ + store, + space +}: { store?: Store; space?: SpaceResource } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext } = useGettext() + const { scrollToResource } = useScrollTo() + + const clientService = useClientService() + const currentFolder = computed((): Resource => store.getters['Files/currentFolder']) + const files = computed((): Array => store.getters['Files/files']) + const ancestorMetaData = computed(() => store.getters['Files/ancestorMetaData']) + + const checkNewFolderName = (folderName) => { + if (folderName.trim() === '') { + return $gettext('Folder name cannot be empty') + } + + if (/[/]/.test(folderName)) { + return $gettext('Folder name cannot contain "/"') + } + + if (folderName === '.') { + return $gettext('Folder name cannot be equal to "."') + } + + if (folderName === '..') { + return $gettext('Folder name cannot be equal to ".."') + } + + const exists = unref(files).find((file) => file.name === folderName) + + if (exists) { + return $gettext('%{name} already exists', { name: folderName }, true) + } + + return null + } + + const loadIndicatorsForNewFile = computed(() => { + return isLocationSpacesActive(router, 'files-spaces-generic') && space.driveType !== 'share' + }) + + const addNewFolder = async (folderName) => { + folderName = folderName.trimEnd() + + try { + const path = join(unref(currentFolder).path, folderName) + const resource = await (clientService.webdav as WebDAV).createFolder(space, { + path + }) + + if (unref(loadIndicatorsForNewFile)) { + resource.indicators = getIndicators({ resource, ancestorMetaData: unref(ancestorMetaData) }) + } + + store.commit('Files/UPSERT_RESOURCE', resource) + store.dispatch('hideModal') + + store.dispatch('showMessage', { + title: $gettext('"%{folderName}" was created successfully', { folderName }) + }) + + await nextTick() + scrollToResource(resource.id, { forceScroll: true }) + } catch (error) { + console.error(error) + store.dispatch('showErrorMessage', { + title: $gettext('Failed to create folder'), + error + }) + } + } + + const handler = () => { + const checkInputValue = (value) => { + store.dispatch('setModalInputErrorMessage', checkNewFolderName(value)) + } + let defaultName = $gettext('New folder') + + if (unref(files).some((f) => f.name === defaultName)) { + defaultName = resolveFileNameDuplicate(defaultName, '', unref(files)) + } + + const inputSelectionRange = null + + const modal = { + variation: 'passive', + title: $gettext('Create a new folder'), + cancelText: $gettext('Cancel'), + confirmText: $gettext('Create'), + hasInput: true, + inputValue: defaultName, + inputLabel: $gettext('Folder name'), + inputError: checkNewFolderName(defaultName), + inputSelectionRange, + onCancel: () => store.dispatch('hideModal'), + onConfirm: addNewFolder, + onInput: checkInputValue + } + + store.dispatch('createModal', modal) + } + + const actions = computed((): FileAction[] => { + return [ + { + name: 'create-folder', + icon: 'folder', + handler, + label: () => { + return $gettext('New Folder') + }, + isEnabled: () => { + return unref(currentFolder)?.canCreate() + }, + componentType: 'button', + class: 'oc-files-actions-create-new-folder' + } + ] + }) + + return { + actions, + checkNewFolderName, + addNewFolder + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts new file mode 100644 index 00000000000..ed46ce700c0 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateQuicklink.ts @@ -0,0 +1,58 @@ +import quickActions, { canShare } from '../../../quickActions' +import { createQuicklink } from '../../../helpers/share' +import { ShareStatus } from 'web-client/src/helpers/share' + +import { isLocationSharesActive } from '../../../router' +import { computed } from 'vue' +import { useAbility, useClientService, useRouter, useStore } from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { Store } from 'vuex' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' + +export const useFileActionsCreateQuickLink = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const language = useGettext() + const { $gettext } = language + const ability = useAbility() + const clientService = useClientService() + + const handler = async ({ space, resources }: FileActionOptions) => { + const [resource] = resources + await createQuicklink({ + clientService, + resource, + storageId: space?.id || resource?.fileId || resource?.id, + store, + language, + ability + }) + } + + const actions = computed((): FileAction[] => [ + { + name: 'create-quicklink', + icon: quickActions.quicklink.icon, + iconFillType: quickActions.quicklink.iconFillType, + label: () => $gettext('Copy link'), + handler, + isEnabled: ({ resources }) => { + if (resources.length !== 1) { + return false + } + if (isLocationSharesActive(router, 'files-shares-with-me')) { + if (resources[0].status !== ShareStatus.accepted) { + return false + } + } + return canShare(resources[0], store) + }, + componentType: 'button', + class: 'oc-files-actions-create-quicklink-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateSpaceFromResource.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateSpaceFromResource.ts new file mode 100644 index 00000000000..b28c6a08bf4 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateSpaceFromResource.ts @@ -0,0 +1,128 @@ +import { Store } from 'vuex' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { useAbility, useClientService, useLoadingService, useRouter } from 'web-pkg/src/composables' +import { isPersonalSpaceResource } from 'web-client/src/helpers' +import { isLocationSpacesActive } from 'web-pkg/src/router' +import { useCreateSpace, useSpaceHelpers } from 'web-pkg/src/composables/spaces' +import PQueue from 'p-queue' + +export const useFileActionsCreateSpaceFromResource = ({ store }: { store?: Store } = {}) => { + const { can } = useAbility() + const { $gettext, $ngettext } = useGettext() + const loadingService = useLoadingService() + const { createSpace } = useCreateSpace() + const { checkSpaceNameModalInput } = useSpaceHelpers() + const clientService = useClientService() + const router = useRouter() + const hasCreatePermission = computed(() => can('create-all', 'Drive')) + + const confirmAction = async ({ spaceName, resources, space }) => { + const { webdav } = clientService + store.dispatch('hideModal') + const queue = new PQueue({ concurrency: 4 }) + const copyOps = [] + + try { + const createdSpace = await createSpace(spaceName) + store.commit('runtime/spaces/UPSERT_SPACE', createdSpace) + + if (resources.length === 1 && resources[0].isFolder) { + //If a single folder is selected we copy it's content to the Space's root folder + resources = (await webdav.listFiles(space, { path: resources[0].path })).children + } + + for (const resource of resources) { + copyOps.push( + queue.add(() => webdav.copyFiles(space, resource, createdSpace, { path: resource.name })) + ) + } + + await Promise.all(copyOps) + store.dispatch('Files/resetFileSelection') + store.dispatch('showMessage', { + title: $gettext('Space was created successfully') + }) + } catch (error) { + console.error(error) + store.dispatch('showErrorMessage', { + title: $gettext('Creating space failed…'), + error + }) + } + } + const handler = ({ resources, space }: FileActionOptions) => { + const modal = { + variation: 'passive', + title: $ngettext( + 'Create Space from "%{resourceName}"', + 'Create Space from selection', + resources.length, + { + resourceName: resources[0].name + } + ), + message: $ngettext( + 'Create Space with the content of "%{resourceName}".', + 'Create Space with the selected files.', + resources.length, + { + resourceName: resources[0].name + } + ), + contextualHelperLabel: $gettext('The marked elements will be copied.'), + contextualHelperData: { + title: $gettext('Restrictions'), + text: $gettext('Shares, versions and tags will not be copied.') + }, + cancelText: $gettext('Cancel'), + confirmText: $gettext('Create'), + hasInput: true, + inputLabel: $gettext('Space name'), + onInput: checkSpaceNameModalInput, + onCancel: () => store.dispatch('hideModal'), + onConfirm: (spaceName) => + loadingService.addTask(() => confirmAction({ spaceName, space, resources })) + } + + store.dispatch('createModal', modal) + } + + const actions = computed((): FileAction[] => { + return [ + { + name: 'create-space-from-resource', + icon: 'function', + handler, + label: () => { + return $gettext('Create Space from selection') + }, + isEnabled: ({ resources, space }) => { + if (!resources.length) { + return false + } + + if (!unref(hasCreatePermission)) { + return false + } + + if ( + !isLocationSpacesActive(router, 'files-spaces-generic') || + !isPersonalSpaceResource(space) + ) { + return false + } + + return true + }, + componentType: 'button', + class: 'oc-files-actions-create-space-from-resource-trigger' + } + ] + }) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsDeclineShare.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsDeclineShare.ts new file mode 100644 index 00000000000..ae6e9e77cb8 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsDeclineShare.ts @@ -0,0 +1,123 @@ +import { triggerShareAction } from '../../../helpers/share/triggerShareAction' +import { + isLocationSharesActive, + isLocationSpacesActive, + createLocationShares +} from '../../../router' +import { Store } from 'vuex' +import PQueue from 'p-queue' +import { ShareStatus } from 'web-client/src/helpers/share' +import { + useCapabilityFilesSharingResharing, + useCapabilityShareJailEnabled, + useClientService, + useLoadingService, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' + +export const useFileActionsDeclineShare = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $ngettext } = useGettext() + + const hasResharing = useCapabilityFilesSharingResharing() + const hasShareJail = useCapabilityShareJailEnabled() + const clientService = useClientService() + const loadingService = useLoadingService() + + const handler = async ({ resources }: FileActionOptions) => { + const errors = [] + const triggerPromises = [] + const triggerQueue = new PQueue({ concurrency: 4 }) + resources.forEach((resource) => { + triggerPromises.push( + triggerQueue.add(async () => { + try { + const share = await triggerShareAction( + resource, + ShareStatus.declined, + unref(hasResharing), + unref(hasShareJail), + clientService.owncloudSdk + ) + if (share) { + store.commit('Files/UPDATE_RESOURCE', share) + } + } catch (error) { + console.error(error) + errors.push(error) + } + }) + ) + }) + await Promise.all(triggerPromises) + + if (errors.length === 0) { + store.dispatch('Files/resetFileSelection') + + if (isLocationSpacesActive(router, 'files-spaces-generic')) { + store.dispatch('showMessage', { + title: $ngettext( + 'The selected share was declined successfully', + 'The selected shares were declined successfully', + resources.length + ) + }) + router.push(createLocationShares('files-shares-with-me')) + } + + return + } + + store.dispatch('showErrorMessage', { + title: $ngettext( + 'Failed to decline the selected share', + 'Failed to decline selected shares', + resources.length + ), + errors + }) + } + + const actions = computed((): FileAction[] => [ + { + name: 'decline-share', + icon: 'spam-3', + handler: (args) => loadingService.addTask(() => handler(args)), + label: ({ resources }) => $ngettext('Decline share', 'Decline shares', resources.length), + isEnabled: ({ space, resources }) => { + if ( + !isLocationSharesActive(router, 'files-shares-with-me') && + !isLocationSpacesActive(router, 'files-spaces-generic') + ) { + return false + } + if (resources.length === 0) { + return false + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + (space?.driveType !== 'share' || resources.length > 1 || resources[0].path !== '/') + ) { + return false + } + + const declineDisabled = resources.some((resource) => { + return resource.status === ShareStatus.declined + }) + return !declineDisabled + }, + componentType: 'button', + class: 'oc-files-actions-decline-share-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsDelete.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsDelete.ts new file mode 100644 index 00000000000..b59c4ba438c --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsDelete.ts @@ -0,0 +1,137 @@ +import { useFileActionsDeleteResources } from '../helpers/useFileActionsDeleteResources' +import { Store } from 'vuex' +import { + isLocationPublicActive, + isLocationSpacesActive, + isLocationTrashActive, + isLocationCommonActive +} from '../../../router' +import { isProjectSpaceResource } from 'web-client/src/helpers' +import { + useCapabilityFilesPermanentDeletion, + useCapabilitySpacesEnabled, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { computed, unref } from 'vue' + +export const useFileActionsDelete = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const hasSpaces = useCapabilitySpacesEnabled() + const hasPermanentDeletion = useCapabilityFilesPermanentDeletion() + const { displayDialog, filesList_delete } = useFileActionsDeleteResources({ store }) + + const { $gettext } = useGettext() + + const handler = ({ + space, + resources, + deletePermanent + }: FileActionOptions & { deletePermanent: boolean }) => { + if (isLocationCommonActive(router, 'files-common-search')) { + resources = resources.filter( + (r) => + r.canBeDeleted() && (!unref(hasSpaces) || !r.isShareRoot()) && !isProjectSpaceResource(r) + ) + } + if (deletePermanent) { + displayDialog(space, resources) + return + } + + filesList_delete(resources) + } + + const actions = computed((): FileAction[] => [ + { + name: 'delete', + icon: 'delete-bin-5', + label: ({ resources }) => { + const deleteLabel = $gettext('Delete') + + if (isLocationCommonActive(router, 'files-common-search') && resources.length > 1) { + const deletableResourcesCount = resources.filter( + (r) => + r.canBeDeleted() && + (!unref(hasSpaces) || !r.isShareRoot()) && + !isProjectSpaceResource(r) + ).length + return `${deleteLabel} (${deletableResourcesCount.toString()})` + } + + return deleteLabel + }, + handler: ({ space, resources }) => handler({ space, resources, deletePermanent: false }), + isEnabled: ({ space, resources }) => { + if ( + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationPublicActive(router, 'files-public-link') && + !isLocationCommonActive(router, 'files-common-search') + ) { + return false + } + + if (resources.length === 0) { + return false + } + + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + space?.driveType === 'share' && + resources[0].path === '/' + ) { + return false + } + + if (isLocationCommonActive(router, 'files-common-search')) { + return resources.some( + (r) => + r.canBeDeleted() && + (!unref(hasSpaces) || !r.isShareRoot()) && + !isProjectSpaceResource(r) + ) + } + + const deleteDisabled = resources.some((resource) => { + return !resource.canBeDeleted() + }) + return !deleteDisabled + }, + componentType: 'button', + class: 'oc-files-actions-delete-trigger' + }, + { + // this menu item is ONLY for the trashbin (permanently delete a file/folder) + name: 'delete-permanent', + icon: 'delete-bin-5', + label: () => $gettext('Delete'), + handler: ({ space, resources }) => handler({ space, resources, deletePermanent: true }), + isEnabled: ({ space, resources }) => { + if (!isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + if (!unref(hasPermanentDeletion)) { + return false + } + + if ( + isProjectSpaceResource(space) && + !space.canRemoveFromTrashbin({ user: store.getters.user }) + ) { + return false + } + + return resources.length > 0 + }, + componentType: 'button', + class: 'oc-files-actions-delete-permanent-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsDownloadArchive.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsDownloadArchive.ts new file mode 100644 index 00000000000..0fc05aa9cbd --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsDownloadArchive.ts @@ -0,0 +1,166 @@ +import { + isLocationCommonActive, + isLocationPublicActive, + isLocationSharesActive, + isLocationSpacesActive +} from '../../../router' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import path from 'path' +import first from 'lodash-es/first' +import { isProjectSpaceResource, isPublicSpaceResource, Resource } from 'web-client/src/helpers' +import { Store } from 'vuex' +import { computed, unref } from 'vue' +import { + useLoadingService, + usePublicLinkPassword, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { useGettext } from 'vue3-gettext' +import { useArchiverService } from 'web-pkg/src/composables/archiverService' +import { formatFileSize } from 'web-pkg/src/helpers/filesize' + +export const useFileActionsDownloadArchive = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const loadingService = useLoadingService() + const archiverService = useArchiverService() + const { $ngettext, $gettext, interpolate: $gettextInterpolate, current } = useGettext() + const publicLinkPassword = usePublicLinkPassword({ store }) + const isFilesAppActive = useIsFilesAppActive() + + const handler = ({ space, resources }: FileActionOptions) => { + if (resources.length > 1) { + // the handler can be triggered successfully if project spaces are selected along with other files. + // but we must filter out the project spaces in such a case (only the other selected files are allowed for download). + resources = resources.filter((r) => r.canDownload() && !isProjectSpaceResource(r)) + } + + const fileOptions = unref(archiverService.fileIdsSupported) + ? { + fileIds: resources.map((resource) => resource.fileId) + } + : { + dir: path.dirname(first(resources).path) || '/', + files: resources.map((resource) => resource.name) + } + return archiverService + .triggerDownload({ + ...fileOptions, + ...(space && + isPublicSpaceResource(space) && { + publicToken: space.id as string, + publicLinkPassword: unref(publicLinkPassword) + }) + }) + .catch((e) => { + console.error(e) + store.dispatch('showErrorMessage', { + title: $ngettext( + 'Failed to download the selected folder.', // on single selection only available for folders + 'Failed to download the selected files.', // on multi selection available for files+folders + resources.length + ), + error: e + }) + }) + } + + const areArchiverLimitsExceeded = (resources: Resource[]) => { + const archiverCapabilities = unref(archiverService.capability) + if (!archiverCapabilities) { + return + } + + const selectedFilesSize = resources.reduce( + (accumulator, currentValue) => accumulator + parseInt(`${currentValue.size}`), + 0 + ) + + return selectedFilesSize > parseInt(archiverCapabilities.max_size) + } + + const actions = computed((): FileAction[] => { + return [ + { + name: 'download-archive', + icon: 'inbox-archive', + handler: async (args) => { + await loadingService.addTask(() => handler(args)) + }, + label: ({ resources }) => { + const downloadLabel = $gettext('Download') + + if (isLocationCommonActive(router, 'files-common-search') && resources.length > 1) { + const downloadableResourcesCount = resources.filter( + (r) => r.canDownload() && !isProjectSpaceResource(r) + ).length + return `${downloadLabel} (${downloadableResourcesCount.toString()})` + } + + return downloadLabel + }, + disabledTooltip: ({ resources }) => { + return areArchiverLimitsExceeded(resources) + ? $gettextInterpolate( + $gettext('The selection exceeds the allowed archive size (max. %{maxSize})'), + { + maxSize: formatFileSize(unref(archiverService.capability).max_size, current) + } + ) + : '' + }, + isDisabled: ({ resources }) => areArchiverLimitsExceeded(resources), + isEnabled: ({ resources }) => { + if ( + unref(isFilesAppActive) && + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationPublicActive(router, 'files-public-link') && + !isLocationCommonActive(router, 'files-common-favorites') && + !isLocationCommonActive(router, 'files-common-search') && + !isLocationSharesActive(router, 'files-shares-with-me') && + !isLocationSharesActive(router, 'files-shares-with-others') && + !isLocationSharesActive(router, 'files-shares-via-link') && + !isLocationCommonActive(router, 'files-common-search') + ) { + return false + } + if (!unref(archiverService.available)) { + return false + } + + if (resources.length === 0) { + return false + } + if (resources.length === 1 && !resources[0].isFolder) { + return false + } + if (resources.length > 1 && resources.every((r) => isProjectSpaceResource(r))) { + return false + } + if (isProjectSpaceResource(resources[0]) && resources[0].disabled) { + return false + } + if ( + !unref(archiverService.fileIdsSupported) && + isLocationCommonActive(router, 'files-common-favorites') + ) { + return false + } + + const downloadDisabled = resources.some((resource) => { + return !resource.canDownload() + }) + return !downloadDisabled + }, + componentType: 'button', + class: 'oc-files-actions-download-archive-trigger' + } + ] + }) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsDownloadFile.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsDownloadFile.ts new file mode 100644 index 00000000000..6233d0c578c --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsDownloadFile.ts @@ -0,0 +1,62 @@ +import { + isLocationCommonActive, + isLocationPublicActive, + isLocationSharesActive, + isLocationSpacesActive +} from '../../../router' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import { useRouter } from 'web-pkg/src/composables' +import { FileAction, FileActionOptions, useIsSearchActive } from 'web-pkg/src/composables/actions' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useDownloadFile } from 'web-pkg/src/composables/download/useDownloadFile' + +export const useFileActionsDownloadFile = () => { + const router = useRouter() + const { $gettext } = useGettext() + const isFilesAppActive = useIsFilesAppActive() + const isSearchActive = useIsSearchActive() + const { downloadFile } = useDownloadFile() + const handler = ({ resources }: FileActionOptions) => { + downloadFile(resources[0]) + } + + const actions = computed((): FileAction[] => [ + { + name: 'download-file', + icon: 'file-download', + handler, + label: () => { + return $gettext('Download') + }, + isEnabled: ({ resources }) => { + if ( + unref(isFilesAppActive) && + !unref(isSearchActive) && + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationPublicActive(router, 'files-public-link') && + !isLocationCommonActive(router, 'files-common-favorites') && + !isLocationCommonActive(router, 'files-common-search') && + !isLocationSharesActive(router, 'files-shares-with-me') && + !isLocationSharesActive(router, 'files-shares-with-others') && + !isLocationSharesActive(router, 'files-shares-via-link') + ) { + return false + } + if (resources.length !== 1) { + return false + } + if (resources[0].isFolder) { + return false + } + return resources[0].canDownload() + }, + componentType: 'button', + class: 'oc-files-actions-download-file-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsEmptyTrashBin.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsEmptyTrashBin.ts new file mode 100644 index 00000000000..1a3ed891981 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsEmptyTrashBin.ts @@ -0,0 +1,110 @@ +import { Store } from 'vuex' +import { isLocationTrashActive } from '../../../router' +import { buildWebDavFilesTrashPath } from '../../../helpers/resources' +import { buildWebDavSpacesTrashPath, SpaceResource } from 'web-client/src/helpers' +import { isProjectSpaceResource } from 'web-client/src/helpers' +import { computed, unref } from 'vue' +import { + useCapabilityFilesPermanentDeletion, + useCapabilityShareJailEnabled, + useClientService, + useLoadingService, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' + +export const useFileActionsEmptyTrashBin = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext, $pgettext } = useGettext() + const clientService = useClientService() + const loadingService = useLoadingService() + const hasShareJail = useCapabilityShareJailEnabled() + const hasPermanentDeletion = useCapabilityFilesPermanentDeletion() + + const emptyTrashBin = ({ space }: { space: SpaceResource }) => { + const path = unref(hasShareJail) + ? buildWebDavSpacesTrashPath(space.id) + : buildWebDavFilesTrashPath(store.getters.user.id) + + return clientService.owncloudSdk.fileTrash + .clearTrashBin(path) + .then(() => { + store.dispatch('showMessage', { + title: $gettext('All deleted files were removed') + }) + store.dispatch('Files/clearTrashBin') + }) + .catch((error) => { + console.error(error) + store.dispatch('showErrorMessage', { + title: $pgettext( + 'Error message in case emptying trash bin fails', + 'Failed to empty trash bin' + ), + error + }) + }) + .finally(() => { + store.dispatch('hideModal') + }) + } + + const handler = ({ space }: FileActionOptions) => { + const modal = { + variation: 'danger', + title: $gettext('Empty trash bin'), + cancelText: $gettext('Cancel'), + confirmText: $gettext('Delete'), + message: $gettext( + 'Are you sure you want to permanently delete the listed items? You can’t undo this action.' + ), + hasInput: false, + onCancel: () => store.dispatch('hideModal'), + onConfirm: () => loadingService.addTask(() => emptyTrashBin({ space })) + } + + store.dispatch('createModal', modal) + } + + const actions = computed((): FileAction[] => [ + { + name: 'empty-trash-bin', + icon: 'delete-bin-5', + label: () => $gettext('Empty trash bin'), + handler, + isEnabled: ({ space, resources }) => { + if (!isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + if (!unref(hasPermanentDeletion)) { + return false + } + + if ( + isProjectSpaceResource(space) && + !space.canRemoveFromTrashbin({ user: store.getters.user }) + ) { + return false + } + + return true + }, + isDisabled: () => { + return store.getters['Files/activeFiles'].length === 0 + }, + componentType: 'button', + class: 'oc-files-actions-empty-trash-bin-trigger', + variation: 'danger', + appearance: 'filled' + } + ]) + + return { + actions, + // HACK: exported for unit tests: + emptyTrashBin + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsFavorite.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsFavorite.ts new file mode 100644 index 00000000000..320c14f6f42 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsFavorite.ts @@ -0,0 +1,71 @@ +import { computed, unref } from 'vue' +import { Store } from 'vuex' +import { + useCapabilityFilesFavorites, + useClientService, + useRouter, + useStore +} from 'web-pkg/src/composables' + +import { isLocationCommonActive, isLocationSpacesActive } from '../../../router' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' + +export const useFileActionsFavorite = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext, interpolate: $gettextInterpolate } = useGettext() + const clientService = useClientService() + const hasFavorites = useCapabilityFilesFavorites() + const isFilesAppActive = useIsFilesAppActive() + const handler = ({ resources }: FileActionOptions) => { + store + .dispatch('Files/markFavorite', { + client: clientService.owncloudSdk, + file: resources[0] + }) + .catch((error) => { + const translated = $gettext('Failed to change favorite state of "%{file}"') + const title = $gettextInterpolate(translated, { file: resources[0].name }, true) + store.dispatch('showErrorMessage', { + title: title, + error + }) + }) + } + + const actions = computed((): FileAction[] => [ + { + name: 'favorite', + icon: 'star', + handler, + label: ({ resources }) => { + if (resources[0].starred) { + return $gettext('Remove from favorites') + } + return $gettext('Add to favorites') + }, + isEnabled: ({ resources }) => { + if ( + unref(isFilesAppActive) && + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationCommonActive(router, 'files-common-favorites') + ) { + return false + } + if (resources.length !== 1) { + return false + } + + return unref(hasFavorites) + }, + componentType: 'button', + class: 'oc-files-actions-favorite-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsMove.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsMove.ts new file mode 100644 index 00000000000..65d18bf4212 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsMove.ts @@ -0,0 +1,69 @@ +import { canBeMoved } from '../../../helpers/permissions' +import { + isLocationCommonActive, + isLocationPublicActive, + isLocationSpacesActive +} from '../../../router' +import { Store } from 'vuex' +import { useGettext } from 'vue3-gettext' +import { ActionOptions, FileAction } from 'web-pkg/src/composables/actions' +import { computed, unref } from 'vue' +import { useRouter, useStore } from 'web-pkg/src/composables' + +export const useFileActionsMove = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const language = useGettext() + const { $pgettext } = language + + const isMacOs = computed(() => { + return window.navigator.platform.match('Mac') + }) + + const cutShortcutString = computed(() => { + if (unref(isMacOs)) { + return $pgettext('Keyboard shortcut for macOS for cutting files', '⌘ + X') + } + return $pgettext('Keyboard shortcut for non-macOS systems for cutting files', 'Ctrl + X') + }) + + const handler = ({ space, resources }: ActionOptions) => { + store.dispatch('Files/cutSelectedFiles', { ...language, space, resources }) + } + const actions = computed((): FileAction[] => [ + { + name: 'cut', + icon: 'scissors', + handler, + shortcut: unref(cutShortcutString), + label: () => $pgettext('Action in the files list row to initiate cutting resources', 'Cut'), + isEnabled: ({ resources }) => { + if ( + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationPublicActive(router, 'files-public-link') && + !isLocationCommonActive(router, 'files-common-favorites') + ) { + return false + } + if (resources.length === 0) { + return false + } + + if (!store.getters['Files/currentFolder']) { + return false + } + + const moveDisabled = resources.some((resource) => { + return canBeMoved(resource, store.getters['Files/currentFolder'].path) === false + }) + return !moveDisabled + }, + componentType: 'button', + class: 'oc-files-actions-move-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsNavigate.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsNavigate.ts new file mode 100644 index 00000000000..86cd8da0f1c --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsNavigate.ts @@ -0,0 +1,114 @@ +import { Store } from 'vuex' +import { isSameResource } from '../../../helpers/resource' +import { + createLocationPublic, + createLocationSpaces, + isLocationPublicActive, + isLocationSharesActive, + isLocationTrashActive +} from '../../../router' +import { ShareStatus } from 'web-client/src/helpers/share' +import merge from 'lodash-es/merge' +import { + buildShareSpaceResource, + isShareSpaceResource, + Resource, + SpaceResource +} from 'web-client/src/helpers' +import { configurationManager } from 'web-pkg/src/configuration' +import { createFileRouteOptions } from 'web-pkg/src/helpers/router' +import { useRouter, useStore } from 'web-pkg/src/composables' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { FileAction } from 'web-pkg/src/composables/actions' + +export const useFileActionsNavigate = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $pgettext } = useGettext() + + const getSpace = (space: SpaceResource, resource: Resource) => { + if (space) { + return space + } + const storageId = resource.storageId + // FIXME: Once we have the shareId in the OCS response, we can check for that and early return the share + space = store.getters['runtime/spaces/spaces'].find((space) => space.id === storageId) + if (space) { + return space + } + + return buildShareSpaceResource({ + shareId: resource.shareId, + shareName: resource.name, + serverUrl: configurationManager.serverUrl + }) + } + + const routeName = computed(() => { + if (isLocationPublicActive(router, 'files-public-link')) { + return createLocationPublic('files-public-link') + } + + return createLocationSpaces('files-spaces-generic') + }) + + const actions = computed((): FileAction[] => [ + { + name: 'navigate', + icon: 'folder-open', + label: () => $pgettext('Action in the files list row to open a folder', 'Open folder'), + isEnabled: ({ resources }) => { + if (isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + if (resources.length !== 1) { + return false + } + + const currentFolder = store.getters['Files/currentFolder'] + if (currentFolder !== null && isSameResource(resources[0], currentFolder)) { + return false + } + + if (!resources[0].isFolder || resources[0].type === 'space') { + return false + } + + if ( + isLocationSharesActive(router, 'files-shares-with-me') && + resources[0].status !== ShareStatus.accepted + ) { + return false + } + + return true + }, + componentType: 'router-link', + route: ({ space, resources }) => { + if ( + isShareSpaceResource(space) && + (isLocationSharesActive(router, 'files-shares-with-others') || + isLocationSharesActive(router, 'files-shares-via-link')) + ) { + // FIXME: This is a hacky way to resolve re-shares, but we don't have other options currently + return { name: 'resolvePrivateLink', params: { fileId: resources[0].fileId } } + } + + return merge( + {}, + unref(routeName), + createFileRouteOptions(getSpace(space, resources[0]), { + path: resources[0].path, + fileId: resources[0].fileId + }) + ) + }, + class: 'oc-files-actions-navigate-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts new file mode 100644 index 00000000000..49f167012b1 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsPaste.ts @@ -0,0 +1,118 @@ +import { + isLocationCommonActive, + isLocationPublicActive, + isLocationSpacesActive +} from '../../../router' +import { Store } from 'vuex' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { + useClientService, + useGetMatchingSpace, + useLoadingService, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { Resource, SpaceResource } from 'web-client' + +export const useFileActionsPaste = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const clientService = useClientService() + const loadingService = useLoadingService() + const { getMatchingSpace } = useGetMatchingSpace() + const { $gettext, $pgettext, interpolate: $gettextInterpolate, $ngettext } = useGettext() + + const isMacOs = computed(() => { + return window.navigator.platform.match('Mac') + }) + + const pasteShortcutString = computed(() => { + if (unref(isMacOs)) { + return $pgettext('Keyboard shortcut for macOS for pasting files', '⌘ + V') + } + return $pgettext('Keyboard shortcut for non-macOS systems for pasting files', 'Ctrl + V') + }) + + const handler = async ({ space: targetSpace }: FileActionOptions) => { + const resourceSpaceMapping: Record = + store.getters['Files/clipboardResources'].reduce((acc, resource) => { + if (resource.storageId in acc) { + acc[resource.storageId].resources.push(resource) + return acc + } + + const matchingSpace = getMatchingSpace(resource) + + if (!(matchingSpace.id in acc)) { + acc[matchingSpace.id] = { space: matchingSpace, resources: [] } + } + + acc[matchingSpace.id].resources.push(resource) + return acc + }, {}) + const promises = Object.values(resourceSpaceMapping).map( + ({ space: sourceSpace, resources: resourcesToCopy }) => { + return loadingService.addTask(() => { + return store.dispatch('Files/pasteSelectedFiles', { + targetSpace, + sourceSpace: sourceSpace, + resources: resourcesToCopy, + clientService, + loadingService, + createModal: (...args) => store.dispatch('createModal', ...args), + hideModal: (...args) => store.dispatch('hideModal', ...args), + showMessage: (...args) => store.dispatch('showMessage', ...args), + showErrorMessage: (...args) => store.dispatch('showErrorMessage', ...args), + $gettext, + $gettextInterpolate, + $ngettext + }) + }) + } + ) + await Promise.all(promises) + store.commit('Files/CLEAR_CLIPBOARD') + } + + const actions = computed((): FileAction[] => [ + { + name: 'paste', + icon: 'clipboard', + handler, + label: () => $pgettext('Action in the files list row to initiate pasting resources', 'Paste'), + shortcut: unref(pasteShortcutString), + isEnabled: ({ resources }) => { + if (store.getters['Files/clipboardResources'].length === 0) { + return false + } + if ( + !isLocationSpacesActive(router, 'files-spaces-generic') && + !isLocationPublicActive(router, 'files-public-link') && + !isLocationCommonActive(router, 'files-common-favorites') + ) { + return false + } + if (resources.length === 0) { + return false + } + + const currentFolder = store.getters['Files/currentFolder'] + if (isLocationPublicActive(router, 'files-public-link')) { + return currentFolder.canCreate() + } + + // copy can't be restricted in authenticated context, because + // a user always has their home dir with write access + return true + }, + componentType: 'button', + class: 'oc-files-actions-copy-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsRename.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsRename.ts new file mode 100644 index 00000000000..56e35637342 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsRename.ts @@ -0,0 +1,253 @@ +import { Store } from 'vuex' +import { isSameResource } from '../../../helpers/resource' +import { isLocationTrashActive, isLocationSharesActive } from '../../../router' +import { Resource } from 'web-client' +import { dirname, join } from 'path' +import { WebDAV } from 'web-client/src/webdav' +import { + SpaceResource, + isShareSpaceResource, + extractNameWithoutExtension +} from 'web-client/src/helpers' +import { createFileRouteOptions } from 'web-pkg/src/helpers/router' +import { renameResource as _renameResource } from '../../../helpers/resources' +import { computed, unref } from 'vue' +import { useClientService, useLoadingService, useRouter, useStore } from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { useCapabilityFilesSharingCanRename } from 'web-pkg/src/composables/capability' + +export const useFileActionsRename = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext, interpolate: $gettextInterpolate } = useGettext() + const clientService = useClientService() + const loadingService = useLoadingService() + const canRename = useCapabilityFilesSharingCanRename() + + const checkNewName = (resource, newName, parentResources = undefined) => { + const newPath = + resource.path.substring(0, resource.path.length - resource.name.length) + newName + + if (!newName) { + return store.dispatch('setModalInputErrorMessage', $gettext('The name cannot be empty')) + } + + if (/[/]/.test(newName)) { + return store.dispatch('setModalInputErrorMessage', $gettext('The name cannot contain "/"')) + } + + if (newName === '.') { + return store.dispatch( + 'setModalInputErrorMessage', + $gettext('The name cannot be equal to "."') + ) + } + + if (newName === '..') { + return store.dispatch( + 'setModalInputErrorMessage', + $gettext('The name cannot be equal to ".."') + ) + } + + if (/\s+$/.test(newName)) { + return store.dispatch( + 'setModalInputErrorMessage', + $gettext('The name cannot end with whitespace') + ) + } + + const exists = store.getters['Files/files'].find( + (file) => file.path === newPath && resource.name !== newName + ) + if (exists) { + const translated = $gettext('The name "%{name}" is already taken') + return store.dispatch( + 'setModalInputErrorMessage', + $gettextInterpolate(translated, { name: newName }, true) + ) + } + + if (parentResources) { + const exists = parentResources.find( + (file) => file.path === newPath && resource.name !== newName + ) + + if (exists) { + const translated = $gettext('The name "%{name}" is already taken') + + return store.dispatch( + 'setModalInputErrorMessage', + $gettextInterpolate(translated, { name: newName }, true) + ) + } + } + + store.dispatch('setModalInputErrorMessage', null) + } + + const renameResource = async (space: SpaceResource, resource: Resource, newName: string) => { + store.dispatch('toggleModalConfirmButton') + let currentFolder = store.getters['Files/currentFolder'] + + try { + const newPath = join(dirname(resource.path), newName) + await (clientService.webdav as WebDAV).moveFiles(space, resource, space, { + path: newPath + }) + store.dispatch('hideModal') + + const isCurrentFolder = isSameResource(resource, currentFolder) + + if (isShareSpaceResource(space) && resource.isReceivedShare()) { + space.rename(newName) + + if (isCurrentFolder) { + currentFolder = { ...currentFolder } as Resource + currentFolder.name = newName + store.commit('Files/SET_CURRENT_FOLDER', currentFolder) + return router.push( + createFileRouteOptions(space, { + path: '', + fileId: resource.fileId + }) + ) + } + + const sharedResource = { ...resource } + sharedResource.name = newName + store.commit('Files/UPSERT_RESOURCE', sharedResource) + return + } + + if (isCurrentFolder) { + currentFolder = { ...currentFolder } as Resource + _renameResource(space, currentFolder, newPath) + store.commit('SET_CURRENT_FOLDER', currentFolder) + return router.push( + createFileRouteOptions(space, { + path: newPath, + fileId: resource.fileId + }) + ) + } + const fileResource = { ...resource } as Resource + _renameResource(space, fileResource, newPath) + store.commit('Files/UPSERT_RESOURCE', fileResource) + } catch (error) { + console.error(error) + store.dispatch('toggleModalConfirmButton') + let translated = $gettext('Failed to rename "%{file}" to "%{newName}"') + if (error.statusCode === 423) { + translated = $gettext('Failed to rename "%{file}" to "%{newName}" - the file is locked') + } + const title = $gettextInterpolate(translated, { file: resource.name, newName }, true) + store.dispatch('showErrorMessage', { + title, + error + }) + } + } + + const handler = async ({ space, resources }: FileActionOptions) => { + const currentFolder = store.getters['Files/currentFolder'] + let parentResources + if (isSameResource(resources[0], currentFolder)) { + const parentPath = dirname(currentFolder.path) + parentResources = ( + await (clientService.webdav as WebDAV).listFiles(space, { + path: parentPath + }) + ).children + } + + const areFileExtensionsShown = store.state.Files.areFileExtensionsShown + const confirmAction = (newName) => { + if (!areFileExtensionsShown) { + newName = `${newName}.${resources[0].extension}` + } + + return renameResource(space, resources[0], newName) + } + const checkName = (newName) => { + if (!areFileExtensionsShown) { + newName = `${newName}.${resources[0].extension}` + } + checkNewName(resources[0], newName, parentResources) + } + const nameWithoutExtension = extractNameWithoutExtension(resources[0]) + const modalTitle = + !resources[0].isFolder && !areFileExtensionsShown ? nameWithoutExtension : resources[0].name + + const title = $gettextInterpolate( + resources[0].isFolder ? $gettext('Rename folder %{name}') : $gettext('Rename file %{name}'), + { name: modalTitle } + ) + + const inputValue = + !resources[0].isFolder && !areFileExtensionsShown ? nameWithoutExtension : resources[0].name + + const inputSelectionRange = + resources[0].isFolder || !areFileExtensionsShown ? null : [0, nameWithoutExtension.length] + + const modal = { + variation: 'passive', + title, + cancelText: $gettext('Cancel'), + confirmText: $gettext('Rename'), + hasInput: true, + inputValue, + inputSelectionRange, + inputLabel: resources[0].isFolder ? $gettext('Folder name') : $gettext('File name'), + onCancel: () => store.dispatch('hideModal'), + onConfirm: (args) => loadingService.addTask(() => confirmAction(args)), + onInput: checkName + } + + store.dispatch('createModal', modal) + } + + const actions = computed((): FileAction[] => [ + { + name: 'rename', + icon: 'pencil', + label: () => { + return $gettext('Rename') + }, + handler, + isEnabled: ({ resources }) => { + if (isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + if (isLocationSharesActive(router, 'files-shares-with-me') && !unref(canRename)) { + return false + } + if (resources.length !== 1) { + return false + } + + // FIXME: Remove this check as soon as renaming shares works as expected + // see https://github.com/owncloud/ocis/issues/4866 + const rootShareIncluded = resources.some((r) => r.shareId && r.path === '/') + if (rootShareIncluded) { + return false + } + + const renameDisabled = resources.some((resource) => { + return !resource.canRename() + }) + return !renameDisabled + }, + componentType: 'button', + class: 'oc-files-actions-rename-trigger' + } + ]) + + return { + actions, + // HACK: exported for unit tests: + checkNewName, + renameResource + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts new file mode 100644 index 00000000000..59e2e52a6c3 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsRestore.ts @@ -0,0 +1,310 @@ +import { Store } from 'vuex' +import { dirname } from 'path' +import { isLocationTrashActive } from '../../../router' + +import { + Resource, + isProjectSpaceResource, + extractExtensionFromFile, + SpaceResource +} from 'web-client/src/helpers' +import { + ResolveStrategy, + ResolveConflict, + resolveFileNameDuplicate, + ConflictDialog +} from '../../../helpers/resource' +import { urlJoin } from 'web-client/src/utils' +import { + useCapabilitySpacesEnabled, + useClientService, + useLoadingService, + useRouter, + useStore +} from 'web-pkg/src/composables' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { LoadingTaskCallbackArguments } from 'web-pkg' + +export const useFileActionsRestore = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext, $ngettext, interpolate: $gettextInterpolate } = useGettext() + const clientService = useClientService() + const loadingService = useLoadingService() + + const hasSpacesEnabled = useCapabilitySpacesEnabled() + + const collectConflicts = async (space: SpaceResource, sortedResources: Resource[]) => { + const existingResourcesCache = {} + const conflicts: Resource[] = [] + const resolvedResources: Resource[] = [] + const missingFolderPaths: string[] = [] + for (const resource of sortedResources) { + const parentPath = dirname(resource.path) + + let existingResources: Resource[] = [] + if (parentPath in existingResourcesCache) { + existingResources = existingResourcesCache[parentPath] + } else { + try { + existingResources = ( + await clientService.webdav.listFiles(space, { + path: parentPath + }) + ).children + } catch (error) { + missingFolderPaths.push(parentPath) + } + existingResourcesCache[parentPath] = existingResources + } + // Check for naming conflict in parent folder and between resources batch + const hasConflict = + existingResources.some((r) => r.name === resource.name) || + resolvedResources.filter((r) => r.id !== resource.id).some((r) => r.path === resource.path) + if (hasConflict) { + conflicts.push(resource) + } else { + resolvedResources.push(resource) + } + } + return { + existingResourcesByPath: existingResourcesCache, + conflicts, + resolvedResources, + missingFolderPaths: missingFolderPaths.filter((path) => !existingResourcesCache[path]?.length) + } + } + + const collectResolveStrategies = async (conflicts: Resource[]) => { + let count = 0 + const resolvedConflicts = [] + const allConflictsCount = conflicts.length + let doForAllConflicts = false + let allConflictsStrategy + for (const conflict of conflicts) { + const isFolder = conflict.type === 'folder' + if (doForAllConflicts) { + resolvedConflicts.push({ + resource: conflict, + strategy: allConflictsStrategy + }) + continue + } + const remainingConflictCount = allConflictsCount - count + const conflictDialog = new ConflictDialog( + (...args) => store.dispatch('createModal', ...args), + (...args) => store.dispatch('hideModal', ...args), + (...args) => store.dispatch('showMessage', ...args), + (...args) => store.dispatch('showErrorMessage', ...args), + $gettext, + $ngettext, + $gettextInterpolate + ) + const resolvedConflict: ResolveConflict = await conflictDialog.resolveFileExists( + { name: conflict.name, isFolder } as Resource, + remainingConflictCount, + remainingConflictCount <= 1, + false + ) + count++ + if (resolvedConflict.doForAllConflicts) { + doForAllConflicts = true + allConflictsStrategy = resolvedConflict.strategy + } + resolvedConflicts.push({ + resource: conflict, + strategy: resolvedConflict.strategy + }) + } + return resolvedConflicts + } + + const createFolderStructure = async ( + space: SpaceResource, + path: string, + existingPaths: string[] + ) => { + const { webdav } = clientService + + const pathSegments = path.split('/').filter(Boolean) + let parentPath = '' + for (const subFolder of pathSegments) { + const folderPath = urlJoin(parentPath, subFolder) + if (existingPaths.includes(folderPath)) { + parentPath = urlJoin(parentPath, subFolder) + continue + } + + try { + await webdav.createFolder(space, { path: folderPath }) + } catch (ignored) {} + + existingPaths.push(folderPath) + parentPath = folderPath + } + + return { + existingPaths + } + } + + const restoreResources = async ( + space: SpaceResource, + resources: Resource[], + missingFolderPaths: string[], + { setProgress }: LoadingTaskCallbackArguments + ) => { + const restoredResources = [] + const failedResources = [] + const errors = [] + + let createdFolderPaths = [] + for (const [i, resource] of resources.entries()) { + const parentPath = dirname(resource.path) + if (missingFolderPaths.includes(parentPath)) { + const { existingPaths } = await createFolderStructure(space, parentPath, createdFolderPaths) + createdFolderPaths = existingPaths + } + + try { + await clientService.webdav.restoreFile(space, resource, resource, { + overwrite: true + }) + restoredResources.push(resource) + } catch (e) { + console.error(e) + errors.push(e) + failedResources.push(resource) + } finally { + setProgress({ total: resources.length, current: i + 1 }) + } + } + + // success handler (for partial and full success) + if (restoredResources.length) { + store.dispatch('Files/removeFilesFromTrashbin', restoredResources) + let translated + const translateParams: any = {} + if (restoredResources.length === 1) { + translated = $gettext('%{resource} was restored successfully') + translateParams.resource = restoredResources[0].name + } else { + translated = $gettext('%{resourceCount} files restored successfully') + translateParams.resourceCount = restoredResources.length + } + store.dispatch('showMessage', { + title: $gettextInterpolate(translated, translateParams, true) + }) + } + + // failure handler (for partial and full failure) + if (failedResources.length) { + let translated + const translateParams: any = {} + if (failedResources.length === 1) { + translated = $gettext('Failed to restore "%{resource}"') + translateParams.resource = failedResources[0].name + } else { + translated = $gettext('Failed to restore %{resourceCount} files') + translateParams.resourceCount = failedResources.length + } + store.dispatch('showErrorMessage', { + title: $gettextInterpolate(translated, translateParams, true), + errors + }) + } + + // Reload quota + if (unref(hasSpacesEnabled)) { + const graphClient = clientService.graphAuthenticated + const driveResponse = await graphClient.drives.getDrive(space.id as string) + store.commit('runtime/spaces/UPDATE_SPACE_FIELD', { + id: driveResponse.data.id, + field: 'spaceQuota', + value: driveResponse.data.quota + }) + } else { + const user = await clientService.owncloudSdk.users.getUser(store.getters.user.id) + store.commit('SET_QUOTA', user.quota) + } + } + + const handler = async ({ space, resources }: FileActionOptions) => { + // resources need to be sorted by path ASC to recover the parents first in case of deep nested folder structure + const sortedResources = resources.sort((a, b) => a.path.length - b.path.length) + + // collect and request existing files in associated parent folders of each resource + const { existingResourcesByPath, conflicts, resolvedResources, missingFolderPaths } = + await collectConflicts(space, sortedResources) + + // iterate through conflicts and collect resolve strategies + const resolvedConflicts = await collectResolveStrategies(conflicts) + + // iterate through conflicts and behave according to strategy + const filesToOverwrite = resolvedConflicts + .filter((e) => e.strategy === ResolveStrategy.REPLACE) + .map((e) => e.resource) + resolvedResources.push(...filesToOverwrite) + const filesToKeepBoth = resolvedConflicts + .filter((e) => e.strategy === ResolveStrategy.KEEP_BOTH) + .map((e) => e.resource) + + for (let resource of filesToKeepBoth) { + resource = { ...resource } + const parentPath = dirname(resource.path) + const existingResources = existingResourcesByPath[parentPath] || [] + const extension = extractExtensionFromFile(resource) + const resolvedName = resolveFileNameDuplicate(resource.name, extension, [ + ...existingResources, + ...resolvedConflicts.map((e) => e.resource), + ...resolvedResources + ]) + resource.name = resolvedName + resource.path = urlJoin(parentPath, resolvedName) + resolvedResources.push(resource) + } + return loadingService.addTask( + ({ setProgress }) => { + return restoreResources(space, resolvedResources, missingFolderPaths, { setProgress }) + }, + { indeterminate: false } + ) + } + + const actions = computed((): FileAction[] => [ + { + name: 'restore', + icon: 'arrow-go-back', + label: () => $gettext('Restore'), + handler, + isEnabled: ({ space, resources }) => { + if (!isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + if (!resources.every((r) => r.canBeRestored())) { + return false + } + + if ( + isProjectSpaceResource(space) && + !space.isEditor(store.getters.user) && + !space.isManager(store.getters.user) + ) { + return false + } + + return resources.length > 0 + }, + componentType: 'button', + class: 'oc-files-actions-restore-trigger' + } + ]) + + return { + actions, + // HACK: exported for unit tests: + restoreResources + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsSetImage.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsSetImage.ts new file mode 100644 index 00000000000..32cf96151bb --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsSetImage.ts @@ -0,0 +1,119 @@ +import { isLocationSpacesActive } from '../../../router' +import { Store } from 'vuex' +import { + useClientService, + useLoadingService, + useRouter, + useStore, + usePreviewService +} from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { computed } from 'vue' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { Drive } from 'web-client/src/generated' +import { buildSpace } from 'web-client/src/helpers' + +export const useFileActionsSetImage = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext } = useGettext() + const clientService = useClientService() + const loadingService = useLoadingService() + const previewService = usePreviewService() + + const handler = async ({ space, resources }: FileActionOptions) => { + const graphClient = clientService.graphAuthenticated + const storageId = space?.id as string + const sourcePath = resources[0].path + const destinationPath = `/.space/${resources[0].name}` + const { copyFiles, getFileInfo, createFolder } = clientService.webdav + + try { + try { + await getFileInfo(space, { path: '.space' }) + } catch (_) { + await createFolder(space, { path: '.space' }) + } + + if (sourcePath !== destinationPath) { + await copyFiles( + space, + { path: sourcePath }, + space, + { path: destinationPath }, + { overwrite: true } + ) + } + + const file = await getFileInfo(space, { path: destinationPath }) + + const { data: updatedDriveData } = await graphClient.drives.updateDrive( + storageId, + { + special: [ + { + specialFolder: { + name: 'image' + }, + id: file.id as string + } + ] + } as Drive, + {} + ) + + store.commit('runtime/spaces/UPDATE_SPACE_FIELD', { + id: storageId, + field: 'spaceImageData', + value: buildSpace(updatedDriveData).spaceImageData + }) + + store.dispatch('showMessage', { + title: $gettext('Space image was set successfully') + }) + } catch (error) { + console.error(error) + store.dispatch('showErrorMessage', { + title: $gettext('Failed to set space image'), + error + }) + } + } + + const actions = computed((): FileAction[] => [ + { + name: 'set-space-image', + icon: 'image-edit', + handler: (args) => loadingService.addTask(() => handler(args)), + label: () => { + return $gettext('Set as space image') + }, + isEnabled: ({ space, resources }) => { + if (resources.length !== 1) { + return false + } + if (!resources[0].mimeType) { + return false + } + if (!previewService.isMimetypeSupported(resources[0].mimeType, true)) { + return false + } + + if (!isLocationSpacesActive(router, 'files-spaces-generic')) { + return false + } + if (!space) { + return false + } + + return space.canEditImage({ user: store.getters.user }) + }, + componentType: 'button', + class: 'oc-files-actions-set-space-image-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsShowActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsShowActions.ts new file mode 100644 index 00000000000..0e2dcf820e8 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsShowActions.ts @@ -0,0 +1,48 @@ +import { isLocationTrashActive } from '../../../router' +import { eventBus } from 'web-pkg/src/services/eventBus' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' +import { computed, unref } from 'vue' +import { useRouter } from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import { FileAction } from 'web-pkg/src/composables/actions' + +export const useFileActionsShowActions = () => { + const router = useRouter() + const { $gettext } = useGettext() + const isFilesAppActive = useIsFilesAppActive() + + const handler = () => { + // we don't have details in the trashbin, yet. the actions panel is the default + // panel at the moment, so we need to use `null` as panel name for trashbins. + // unconditionally return hardcoded `actions` once we have a dedicated + // details panel in trashbins. + const panelName = isLocationTrashActive(router, 'files-trash-generic') ? null : 'actions' + eventBus.publish(SideBarEventTopics.openWithPanel, panelName) + } + + const actions = computed((): FileAction[] => [ + { + name: 'show-actions', + icon: 'slideshow-3', + label: () => $gettext('All Actions'), + handler, + isEnabled: ({ resources }) => { + // sidebar is currently only available inside files app + if (!unref(isFilesAppActive)) { + return false + } + + // we don't have batch actions in the right sidebar, yet. + // return hardcoded `true` in all cases once we have them. + return resources.length === 1 + }, + componentType: 'button', + class: 'oc-files-actions-show-actions-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsShowDetails.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsShowDetails.ts new file mode 100644 index 00000000000..7b285120582 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsShowDetails.ts @@ -0,0 +1,48 @@ +import { Store } from 'vuex' +import { isLocationTrashActive } from '../../../router' +import { eventBus } from 'web-pkg/src/services/eventBus' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter, useStore } from 'web-pkg/src/composables' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import { FileAction } from 'web-pkg/src/composables/actions/types' + +export const useFileActionsShowDetails = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + + const { $gettext } = useGettext() + const isFilesAppActive = useIsFilesAppActive() + + const actions = computed((): FileAction[] => [ + { + name: 'show-details', + icon: 'information', + componentType: 'button', + class: 'oc-files-actions-show-details-trigger', + label: () => $gettext('Details'), + // we don't have details in the trashbin, yet. + // remove trashbin route rule once we have them. + isEnabled: ({ resources }) => { + // sidebar is currently only available inside files app + if (!unref(isFilesAppActive)) { + return false + } + + if (isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + return resources.length > 0 + }, + handler({ resources }) { + store.commit('Files/SET_FILE_SELECTION', resources) + eventBus.publish(SideBarEventTopics.open) + } + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts new file mode 100644 index 00000000000..e39191a833e --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsShowEditTags.ts @@ -0,0 +1,51 @@ +import { Store } from 'vuex' +import { eventBus } from 'web-pkg' +import { useCapabilityFilesTags, useRouter, useStore } from 'web-pkg/src/composables' +import { isLocationTrashActive, isLocationPublicActive } from '../../../router' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' +import { computed, unref } from 'vue' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' +import { useGettext } from 'vue3-gettext' + +export const useFileActionsShowEditTags = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext } = useGettext() + const isFilesAppActive = useIsFilesAppActive() + const hasTags = useCapabilityFilesTags() + + const handler = ({ resources }: FileActionOptions) => { + store.commit('Files/SET_FILE_SELECTION', resources) + eventBus.publish(SideBarEventTopics.openWithPanel, 'tags') + } + + const actions = computed((): FileAction[] => [ + { + name: 'show-edit-tags', + icon: 'price-tag-3', + label: () => $gettext('Add or edit tags'), + handler, + isEnabled: ({ resources }) => { + // sidebar is currently only available inside files app + if (!unref(isFilesAppActive) || !unref(hasTags)) { + return false + } + + if ( + isLocationTrashActive(router, 'files-trash-generic') || + isLocationPublicActive(router, 'files-public-link') + ) { + return false + } + return resources.length === 1 && resources[0].canEditTags() + }, + componentType: 'button', + class: 'oc-files-actions-show-edit-tags-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsShowShares.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsShowShares.ts new file mode 100644 index 00000000000..8c7c7546fd7 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsShowShares.ts @@ -0,0 +1,58 @@ +import quickActions, { canShare } from '../../../quickActions' +import { isLocationSharesActive, isLocationTrashActive } from '../../../router' +import { ShareStatus } from 'web-client/src/helpers/share' +import { useIsFilesAppActive } from '../helpers/useIsFilesAppActive' +import { eventBus } from 'web-pkg/src/services/eventBus' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' +import { computed, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { useRouter, useStore } from 'web-pkg/src/composables' +import { Store } from 'vuex' +import { FileAction, FileActionOptions } from 'web-pkg/src/composables/actions' + +export const useFileActionsShowShares = ({ store }: { store?: Store } = {}) => { + store = store || useStore() + const router = useRouter() + const { $gettext } = useGettext() + const isFilesAppActive = useIsFilesAppActive() + + const handler = ({ resources }: FileActionOptions) => { + store.commit('Files/SET_FILE_SELECTION', resources) + eventBus.publish(SideBarEventTopics.openWithPanel, 'sharing#peopleShares') + } + + const actions = computed((): FileAction[] => [ + { + name: 'show-shares', + icon: quickActions.collaborators.icon, + iconFillType: quickActions.collaborators.iconFillType, + label: () => $gettext('Share'), + handler, + isEnabled: ({ resources }) => { + // sidebar is currently only available inside files app + if (!unref(isFilesAppActive)) { + return false + } + + if (isLocationTrashActive(router, 'files-trash-generic')) { + return false + } + if (resources.length !== 1) { + return false + } + if (isLocationSharesActive(router, 'files-shares-with-me')) { + if (resources[0].status !== ShareStatus.accepted) { + return false + } + } + return canShare(resources[0], store) + }, + componentType: 'button', + class: 'oc-files-actions-show-shares-trigger' + } + ]) + + return { + actions + } +} diff --git a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts new file mode 100644 index 00000000000..042ae26b94a --- /dev/null +++ b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts @@ -0,0 +1,259 @@ +import { Store } from 'vuex' +import { cloneStateObject } from '../../../helpers/store' +import { isSameResource } from '../../../helpers/resource' +import { buildWebDavFilesTrashPath } from '../../../helpers/resources' +import { buildWebDavSpacesTrashPath, Resource, SpaceResource } from 'web-client/src/helpers' +import PQueue from 'p-queue' +import { isLocationSpacesActive } from '../../../router' +import { dirname } from 'path' +import { createFileRouteOptions } from 'web-pkg/src/helpers/router' +import { computed, unref } from 'vue' +import { + useCapabilitySpacesEnabled, + useCapabilityShareJailEnabled, + useClientService, + useRouter, + useStore, + useLoadingService, + useRouteQuery, + queryItemAsString, + useGetMatchingSpace +} from 'web-pkg/src/composables' +import { useGettext } from 'vue3-gettext' +import { ref } from 'vue' + +export const useFileActionsDeleteResources = ({ store }: { store?: Store }) => { + store = store || useStore() + const router = useRouter() + const language = useGettext() + const { getMatchingSpace } = useGetMatchingSpace() + const { $gettext, $ngettext, interpolate: $gettextInterpolate } = language + const hasShareJail = useCapabilityShareJailEnabled() + const hasSpacesEnabled = useCapabilitySpacesEnabled() + const clientService = useClientService() + const loadingService = useLoadingService() + const { owncloudSdk } = clientService + + const queue = new PQueue({ concurrency: 4 }) + const deleteOps = [] + const resourcesToDelete = ref([]) + + const currentPageQuery = useRouteQuery('page', '1') + const currentPage = computed(() => { + return parseInt(queryItemAsString(unref(currentPageQuery))) + }) + + const itemsPerPageQuery = useRouteQuery('items-per-page', '1') + const itemsPerPage = computed(() => { + return parseInt(queryItemAsString(unref(itemsPerPageQuery))) + }) + + const resources = computed(() => { + return cloneStateObject(unref(resourcesToDelete)) + }) + + const dialogTitle = computed(() => { + const currentResources = unref(resources) + const isFolder = currentResources[0].type === 'folder' + let title = null + + if (currentResources.length === 1) { + if (isFolder) { + title = $gettext('Permanently delete folder %{name}') + } else { + title = $gettext('Permanently delete file %{name}') + } + return $gettextInterpolate( + title, + { + name: currentResources[0].name + }, + true + ) + } + + title = $ngettext( + 'Permanently delete selected resource?', + 'Permanently delete %{amount} selected resources?', + currentResources.length + ) + + return $gettextInterpolate(title, { amount: currentResources.length }, false) + }) + + const dialogMessage = computed(() => { + const currentResources = unref(resources) + const isFolder = currentResources[0].type === 'folder' + + if (currentResources.length === 1) { + if (isFolder) { + return $gettext( + 'Are you sure you want to delete this folder? All its content will be permanently removed. This action cannot be undone.' + ) + } + return $gettext( + 'Are you sure you want to delete this file? All its content will be permanently removed. This action cannot be undone.' + ) + } + + return $gettext( + 'Are you sure you want to delete all selected resources? All their content will be permanently removed. This action cannot be undone.' + ) + }) + + const trashbin_deleteOp = (space, resource) => { + const path = unref(hasShareJail) + ? buildWebDavSpacesTrashPath(space.id) + : buildWebDavFilesTrashPath(store.getters.user.id) + + return owncloudSdk.fileTrash + .clearTrashBin(path, resource.id) + .then(() => { + store.dispatch('Files/removeFilesFromTrashbin', [resource]) + const translated = $gettext('"%{file}" was deleted successfully') + store.dispatch('showMessage', { + title: $gettextInterpolate(translated, { file: resource.name }, true) + }) + }) + .catch((error) => { + if (error.statusCode === 423) { + // TODO: we need a may retry option .... + const p = queue.add(() => { + return trashbin_deleteOp(space, resource) + }) + deleteOps.push(p) + return + } + + console.error(error) + const translated = $gettext('Failed to delete "%{file}"') + store.dispatch('showErrorMessage', { + title: $gettextInterpolate(translated, { file: resource.name }, true), + error + }) + }) + } + + const trashbin_delete = (space: SpaceResource) => { + // TODO: use clear all if all files are selected + // FIXME: Implement proper batch delete and add loading indicator + for (const file of unref(resources)) { + const p = queue.add(() => { + return trashbin_deleteOp(space, file) + }) + deleteOps.push(p) + } + + Promise.all(deleteOps).then(() => { + store.dispatch('hideModal') + store.dispatch('toggleModalConfirmButton') + }) + } + + const filesList_delete = (resources: Resource[]) => { + resourcesToDelete.value = [...resources] + + const resourceSpaceMapping: Record = + unref(resources).reduce((acc, resource) => { + if (resource.storageId in acc) { + acc[resource.storageId].resources.push(resource) + return acc + } + + const matchingSpace = getMatchingSpace(resource) + + if (!(matchingSpace.id in acc)) { + acc[matchingSpace.id] = { space: matchingSpace, resources: [] } + } + + acc[matchingSpace.id].resources.push(resource) + return acc + }, {}) + + return Object.values(resourceSpaceMapping).map( + ({ space: spaceForDeletion, resources: resourcesForDeletion }) => { + return loadingService.addTask( + (loadingCallbackArgs) => { + return store + .dispatch('Files/deleteFiles', { + ...language, + space: spaceForDeletion, + files: resourcesForDeletion, + clientService, + loadingCallbackArgs + }) + .then(async () => { + // Load quota + if ( + isLocationSpacesActive(router, 'files-spaces-generic') && + !['public', 'share'].includes(spaceForDeletion?.driveType) + ) { + if (unref(hasSpacesEnabled)) { + const graphClient = clientService.graphAuthenticated + const driveResponse = await graphClient.drives.getDrive( + unref(resources)[0].storageId + ) + store.commit('runtime/spaces/UPDATE_SPACE_FIELD', { + id: driveResponse.data.id, + field: 'spaceQuota', + value: driveResponse.data.quota + }) + } else { + const user = await owncloudSdk.users.getUser(store.getters.user.id) + store.commit('SET_QUOTA', user.quota) + } + } + + if ( + unref(resourcesToDelete).length && + isSameResource(unref(resourcesToDelete)[0], store.getters['Files/currentFolder']) + ) { + // current folder is being deleted + return router.push( + createFileRouteOptions(spaceForDeletion, { + path: dirname(unref(resourcesToDelete)[0].path), + fileId: unref(resourcesToDelete)[0].parentFolderId + }) + ) + } + + const activeFilesCount = store.getters['Files/activeFiles'].length + const pageCount = Math.ceil(unref(activeFilesCount) / unref(itemsPerPage)) + if (unref(currentPage) > 1 && unref(currentPage) > pageCount) { + // reset pagination to avoid empty lists (happens when deleting all items on the last page) + currentPageQuery.value = pageCount.toString() + } + }) + }, + { indeterminate: false } + ) + } + ) + } + + const displayDialog = (space: SpaceResource, resources: Resource[]) => { + resourcesToDelete.value = [...resources] + + const modal = { + variation: 'danger', + title: unref(dialogTitle), + message: unref(dialogMessage), + cancelText: $gettext('Cancel'), + confirmText: $gettext('Delete'), + onCancel: () => { + store.dispatch('hideModal') + }, + onConfirm: () => { + trashbin_delete(space) + } + } + + store.dispatch('createModal', modal) + } + + return { + displayDialog, + // HACK: exported for unit tests: + filesList_delete + } +} diff --git a/packages/web-pkg/src/composables/actions/helpers/useIsFilesAppActive.ts b/packages/web-pkg/src/composables/actions/helpers/useIsFilesAppActive.ts new file mode 100644 index 00000000000..a32e5d4927a --- /dev/null +++ b/packages/web-pkg/src/composables/actions/helpers/useIsFilesAppActive.ts @@ -0,0 +1,14 @@ +import { computed, unref } from 'vue' +import { activeApp, useRoute } from 'web-pkg/src/composables' + +const isFilesAppActive = (activeApp: string): boolean => { + // FIXME: we should use this constant but it somehow breaks the unit tests + // return activeApp === FilesApp.appInfo.id + return activeApp === 'files' +} + +export const useIsFilesAppActive = () => { + const currentRoute = useRoute() + + return computed(() => isFilesAppActive(activeApp(unref(currentRoute)))) +} diff --git a/packages/web-pkg/src/composables/filesList/index.ts b/packages/web-pkg/src/composables/filesList/index.ts new file mode 100644 index 00000000000..25cf331cd80 --- /dev/null +++ b/packages/web-pkg/src/composables/filesList/index.ts @@ -0,0 +1 @@ +export * from './useResourceRouteResolver' diff --git a/packages/web-pkg/src/composables/filesList/useResourceRouteResolver.ts b/packages/web-pkg/src/composables/filesList/useResourceRouteResolver.ts new file mode 100644 index 00000000000..ebd48ae9e90 --- /dev/null +++ b/packages/web-pkg/src/composables/filesList/useResourceRouteResolver.ts @@ -0,0 +1,81 @@ +import { unref, Ref } from 'vue' +import { basename } from 'path' + +import { ConfigurationManager } from 'web-pkg/src' +import { useGetMatchingSpace } from 'web-pkg/src/composables' +import { useConfigurationManager } from 'web-pkg/src/composables/configuration' +import { createFileRouteOptions } from 'web-pkg/src/helpers/router' +import { createLocationSpaces, createLocationShares } from '../../router' +import { CreateTargetRouteOptions } from '../../helpers/folderLink/types' +import { Resource, SpaceResource } from 'web-client/src' +import { buildShareSpaceResource } from 'web-client/src/helpers' + +type ResourceRouteResolverOptions = { + configurationManager?: ConfigurationManager + targetRouteCallback?: Ref + space?: Ref +} + +export const useResourceRouteResolver = (options: ResourceRouteResolverOptions, context) => { + const configurationManager = options.configurationManager || useConfigurationManager() + const targetRouteCallback = options.targetRouteCallback + const { getInternalSpace, getMatchingSpace } = useGetMatchingSpace(options) + + const createFolderLink = (createTargetRouteOptions: CreateTargetRouteOptions) => { + if (unref(targetRouteCallback)) { + return unref(targetRouteCallback)(createTargetRouteOptions) + } + + const { path, fileId, resource } = createTargetRouteOptions + let space + if (resource.shareId) { + space = buildShareSpaceResource({ + shareId: resource.shareId, + shareName: basename(resource.shareRoot), + serverUrl: configurationManager.serverUrl + }) + } else if ( + !resource.shareId && + !unref(options.space) && + !getInternalSpace(resource.storageId) + ) { + if (path === '/') { + return createLocationShares('files-shares-with-me') + } + // FIXME: This is a hacky way to resolve re-shares, but we don't have other options currently + return { name: 'resolvePrivateLink', params: { fileId } } + } else { + space = unref(options.space) || getMatchingSpace(resource) + } + if (!space) { + return {} + } + return createLocationSpaces( + 'files-spaces-generic', + createFileRouteOptions(space, { path, fileId }) + ) + } + + const createFileAction = (resource: Resource) => { + let space = unref(options.space) || getMatchingSpace(resource) + if (!space) { + space = buildShareSpaceResource({ + shareId: resource.shareId, + shareName: resource.name, + serverUrl: configurationManager.serverUrl + }) + } + /** + * Triggered when a default action is triggered on a file + * @property {object} resource resource for which the event is triggered + */ + context.emit('fileClick', { space, resources: [resource] }) + } + + return { + createFileAction, + createFolderLink, + getInternalSpace, + getMatchingSpace + } +} diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts index 10f6ddd97e9..cc301755b9a 100644 --- a/packages/web-pkg/src/composables/index.ts +++ b/packages/web-pkg/src/composables/index.ts @@ -18,3 +18,4 @@ export * from './fileListHeaderPosition' export * from './viewMode' export * from './search' export * from './sse' +export * from './resourcesViewDefaults' diff --git a/packages/web-pkg/src/composables/resourcesViewDefaults/index.ts b/packages/web-pkg/src/composables/resourcesViewDefaults/index.ts new file mode 100644 index 00000000000..45cac086e8f --- /dev/null +++ b/packages/web-pkg/src/composables/resourcesViewDefaults/index.ts @@ -0,0 +1 @@ +export * from './useResourcesViewDefaults' diff --git a/packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts b/packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts new file mode 100644 index 00000000000..2a683e5312d --- /dev/null +++ b/packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts @@ -0,0 +1,119 @@ +import { nextTick, computed, unref, Ref } from 'vue' +import { folderService } from '../../services/folder' +import { fileList } from '../../helpers/ui' +import { usePagination, useSort, SortDir, SortField, useRouteName } from 'web-pkg/src/composables' +import { useSideBar } from 'web-pkg/src/composables/sideBar' + +import { + queryItemAsString, + useMutationSubscription, + useRouteQuery, + useStore +} from 'web-pkg/src/composables' +import { determineSortFields as determineResourceTableSortFields } from '../../helpers/ui/resourceTable' +import { determineSortFields as determineResourceTilesSortFields } from '../../helpers/ui/resourceTiles' +import { Task } from 'vue-concurrency' +import { Resource } from 'web-client' +import { useSelectedResources, SelectedResourcesResult } from '../selection' +import { ReadOnlyRef } from 'web-pkg' +import { + useFileListHeaderPosition, + useViewMode, + useViewSize, + ViewModeConstants +} from 'web-pkg/src/composables' + +import { ScrollToResult, useScrollTo } from '../scrollTo' + +interface ResourcesViewDefaultsOptions { + loadResourcesTask?: Task +} + +type ResourcesViewDefaultsResult = { + fileListHeaderY: Ref + refreshFileListHeaderPosition(): void + loadResourcesTask: Task + areResourcesLoading: ReadOnlyRef + storeItems: ReadOnlyRef + sortFields: ReadOnlyRef + paginatedResources: Ref + paginationPages: ReadOnlyRef + paginationPage: ReadOnlyRef + handleSort({ sortBy, sortDir }: { sortBy: string; sortDir: SortDir }): void + sortBy: ReadOnlyRef + sortDir: ReadOnlyRef + viewMode: ReadOnlyRef + viewSize: ReadOnlyRef + selectedResources: Ref + selectedResourcesIds: Ref<(string | number)[]> + isResourceInSelection(resource: Resource): boolean + + sideBarOpen: Ref + sideBarActivePanel: Ref +} & SelectedResourcesResult & + ScrollToResult + +export const useResourcesViewDefaults = ( + options: ResourcesViewDefaultsOptions = {} +): ResourcesViewDefaultsResult => { + const loadResourcesTask = options.loadResourcesTask || folderService.getTask() + const areResourcesLoading = computed(() => { + return loadResourcesTask.isRunning || !loadResourcesTask.last + }) + + const store = useStore() + const { refresh: refreshFileListHeaderPosition, y: fileListHeaderY } = useFileListHeaderPosition() + + const storeItems = computed((): T[] => store.getters['Files/activeFiles'] || []) + + const currentRoute = useRouteName() + const currentViewModeQuery = useRouteQuery( + `${unref(currentRoute)}-view-mode`, + ViewModeConstants.defaultModeName + ) + const currentViewMode = computed((): string => queryItemAsString(currentViewModeQuery.value)) + const viewMode = useViewMode(currentViewMode) + + const currentTilesSizeQuery = useRouteQuery('tiles-size', '1') + const currentTilesSize = computed((): string => String(currentTilesSizeQuery.value)) + const viewSize = useViewSize(currentTilesSize) + + const sortFields = computed((): SortField[] => { + if (unref(viewMode) === ViewModeConstants.tilesView.name) { + return determineResourceTilesSortFields(unref(storeItems)[0]) + } + return determineResourceTableSortFields(unref(storeItems)[0]) + }) + + const { sortBy, sortDir, items, handleSort } = useSort({ items: storeItems, fields: sortFields }) + const { + items: paginatedResources, + total: paginationPages, + page: paginationPage + } = usePagination({ items, perPageStoragePrefix: 'files' }) + + useMutationSubscription(['Files/UPSERT_RESOURCE'], async ({ payload }) => { + await nextTick() + fileList.accentuateItem(payload.id) + }) + + return { + fileListHeaderY, + refreshFileListHeaderPosition, + loadResourcesTask, + areResourcesLoading, + storeItems, + sortFields, + viewMode, + viewSize, + paginatedResources, + paginationPages, + paginationPage, + handleSort, + sortBy, + sortDir, + ...useSelectedResources({ store }), + ...useSideBar(), + ...useScrollTo() + } +} diff --git a/packages/web-pkg/src/composables/router/index.ts b/packages/web-pkg/src/composables/router/index.ts index ae13f1553f1..e443739c191 100644 --- a/packages/web-pkg/src/composables/router/index.ts +++ b/packages/web-pkg/src/composables/router/index.ts @@ -7,3 +7,4 @@ export * from './useRouteParam' export * from './useRouteQuery' export * from './useRouteQueryPersisted' export * from './useRouter' +export * from './useActiveLocation' diff --git a/packages/web-pkg/src/composables/router/useActiveLocation.ts b/packages/web-pkg/src/composables/router/useActiveLocation.ts new file mode 100644 index 00000000000..538001c52ca --- /dev/null +++ b/packages/web-pkg/src/composables/router/useActiveLocation.ts @@ -0,0 +1,27 @@ +import { ref, Ref, watch } from 'vue' +import { useRoute, useRouter } from 'web-pkg/src/composables' +import { ActiveRouteDirectorFunc } from '../../router' + +/** + * watches the current route and re-evaluates the provided active location director + * on each route name update. + * + * @param director + * @param comparatives + */ +export const useActiveLocation = ( + director: ActiveRouteDirectorFunc, + ...comparatives: T[] +): Ref => { + const value = ref(false) + const router = useRouter() + const currentRoute = useRoute() + watch( + currentRoute, + () => { + value.value = director(router, ...comparatives) + }, + { immediate: true } + ) + return value +} diff --git a/packages/web-pkg/src/composables/scrollTo/index.ts b/packages/web-pkg/src/composables/scrollTo/index.ts new file mode 100644 index 00000000000..e59d4c066df --- /dev/null +++ b/packages/web-pkg/src/composables/scrollTo/index.ts @@ -0,0 +1 @@ +export * from './useScrollTo' diff --git a/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts b/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts new file mode 100644 index 00000000000..1a7aaa5044a --- /dev/null +++ b/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts @@ -0,0 +1,67 @@ +import { computed, unref } from 'vue' +import { Resource } from 'web-client/src' +import { queryItemAsString } from 'web-pkg/src/composables/appDefaults/useAppNavigation' +import { useStore } from 'web-pkg/src/composables/store/useStore' +import { eventBus } from 'web-pkg/src/services' +import { useRouteQuery } from 'web-pkg/src/composables' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' + +export interface ScrollToResult { + scrollToResource(resourceId: Resource['id'], options?: { forceScroll?: boolean }): void + scrollToResourceFromRoute(resources: Resource[]): void +} + +export const useScrollTo = (): ScrollToResult => { + const store = useStore() + const scrollToQuery = useRouteQuery('scrollTo') + const detailsQuery = useRouteQuery('details') + const scrollTo = computed(() => { + return queryItemAsString(unref(scrollToQuery)) + }) + const details = computed(() => { + return queryItemAsString(unref(detailsQuery)) + }) + + const scrollToResource = (resourceId: Resource['id'], options = { forceScroll: false }) => { + const resourceElement = document.querySelectorAll( + `[data-item-id='${resourceId}']` + )[0] as HTMLElement + + if (!resourceElement) { + return + } + + const topbarElement = document.getElementById('files-app-bar') + // topbar height + th height + height of one row = offset needed when scrolling top + const topOffset = topbarElement.offsetHeight + resourceElement.offsetHeight * 2 + + if ( + resourceElement.getBoundingClientRect().bottom > window.innerHeight || + resourceElement.getBoundingClientRect().top < topOffset || + options.forceScroll + ) { + resourceElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + + const scrollToResourceFromRoute = (resources: Resource[]) => { + if (!unref(scrollTo) || !resources.length) { + return + } + + const resource = unref(resources).find((r) => r.id === unref(scrollTo)) + if (resource && resource.processing !== true) { + store.commit('Files/SET_FILE_SELECTION', [resource]) + scrollToResource(resource.id, { forceScroll: true }) + + if (unref(details)) { + eventBus.publish(SideBarEventTopics.openWithPanel, unref(details)) + } + } + } + + return { + scrollToResource, + scrollToResourceFromRoute + } +} diff --git a/packages/web-pkg/src/composables/selection/index.ts b/packages/web-pkg/src/composables/selection/index.ts new file mode 100644 index 00000000000..2cc781988da --- /dev/null +++ b/packages/web-pkg/src/composables/selection/index.ts @@ -0,0 +1 @@ +export * from './useSelectedResources' diff --git a/packages/web-pkg/src/composables/selection/useSelectedResources.ts b/packages/web-pkg/src/composables/selection/useSelectedResources.ts new file mode 100644 index 00000000000..f75b00193b0 --- /dev/null +++ b/packages/web-pkg/src/composables/selection/useSelectedResources.ts @@ -0,0 +1,70 @@ +import { computed, unref, WritableComputedRef, Ref } from 'vue' +import { Resource } from 'web-client' +import { useStore } from 'web-pkg/src/composables' +import { Store } from 'vuex' +import { buildShareSpaceResource, SpaceResource } from 'web-client/src/helpers' +import { configurationManager } from 'web-pkg/src/configuration' + +export interface SelectedResourcesResult { + selectedResources: Ref + selectedResourcesIds: Ref<(string | number)[]> + isResourceInSelection(resource: Resource): boolean + selectedResourceSpace?: Ref +} + +interface SelectedResourcesOptions { + store?: Store +} + +export const useSelectedResources = ( + options?: SelectedResourcesOptions +): SelectedResourcesResult => { + const store = options.store || useStore() + + const selectedResources: WritableComputedRef = computed({ + get(): Resource[] { + return store.getters['Files/selectedFiles'] + }, + set(resources) { + store.commit('Files/SET_FILE_SELECTION', resources) + } + }) + const selectedResourcesIds: WritableComputedRef<(string | number)[]> = computed({ + get(): (string | number)[] { + return store.state.Files.selectedIds + }, + set(selectedIds) { + store.commit('Files/SET_SELECTED_IDS', selectedIds) + } + }) + + const isResourceInSelection = (resource: Resource): boolean => { + return unref(selectedResourcesIds).includes(resource.id) + } + + const selectedResourceSpace = computed(() => { + if (unref(selectedResources).length !== 1) { + return null + } + const resource = unref(selectedResources)[0] + const storageId = resource.storageId + // FIXME: Once we have the shareId in the OCS response, we can check for that and early return the share + const space = store.getters['runtime/spaces/spaces'].find((space) => space.id === storageId) + if (space) { + return space + } + + return buildShareSpaceResource({ + shareId: resource.shareId, + shareName: resource.name, + serverUrl: configurationManager.serverUrl + }) + }) + + return { + selectedResources, + selectedResourcesIds, + isResourceInSelection, + selectedResourceSpace + } +} diff --git a/packages/web-pkg/src/composables/spaces/index.ts b/packages/web-pkg/src/composables/spaces/index.ts index 67be360c82f..1bd9b04b0fe 100644 --- a/packages/web-pkg/src/composables/spaces/index.ts +++ b/packages/web-pkg/src/composables/spaces/index.ts @@ -1,2 +1,3 @@ export * from './useSpaceHelpers' export * from './useGetMatchingSpace' +export * from './useCreateSpace' diff --git a/packages/web-pkg/src/composables/spaces/useCreateSpace.ts b/packages/web-pkg/src/composables/spaces/useCreateSpace.ts new file mode 100644 index 00000000000..8f403ef8f28 --- /dev/null +++ b/packages/web-pkg/src/composables/spaces/useCreateSpace.ts @@ -0,0 +1,44 @@ +import { buildSpace } from 'web-client/src/helpers' +import { Drive } from 'web-client/src/generated' +import { useGettext } from 'vue3-gettext' +import { useConfigurationManager, useClientService } from 'web-pkg/src/composables' + +export const useCreateSpace = () => { + const clientService = useClientService() + const { $gettext } = useGettext() + const configurationManager = useConfigurationManager() + + const createSpace = async (name: string) => { + const { graphAuthenticated, webdav } = clientService + const { data: createdSpace } = await graphAuthenticated.drives.createDrive({ name }, {}) + const spaceResource = buildSpace({ + ...createdSpace, + serverUrl: configurationManager.serverUrl + }) + + await webdav.createFolder(spaceResource, { path: '.space' }) + const markdown = await webdav.putFileContents(spaceResource, { + path: '.space/readme.md', + content: $gettext('Here you can add a description for this Space.') + }) + + const { data: updatedSpace } = await graphAuthenticated.drives.updateDrive( + createdSpace.id, + { + special: [ + { + specialFolder: { + name: 'readme' + }, + id: markdown.id as string + } + ] + } as Drive, + {} + ) + + return buildSpace({ ...updatedSpace, serverUrl: configurationManager.serverUrl }) + } + + return { createSpace } +} diff --git a/packages/web-pkg/src/helpers/breadcrumbs.ts b/packages/web-pkg/src/helpers/breadcrumbs.ts new file mode 100644 index 00000000000..46cee701b40 --- /dev/null +++ b/packages/web-pkg/src/helpers/breadcrumbs.ts @@ -0,0 +1,44 @@ +import { eventBus } from 'web-pkg/src/services/eventBus' +import { RouteLocation } from 'vue-router' +import omit from 'lodash-es/omit' +import { BreadcrumbItem } from 'design-system/src/components/OcBreadcrumb/types' +import { v4 as uuidv4 } from 'uuid' + +export const breadcrumbsFromPath = ( + currentRoute: RouteLocation, + resourcePath: string +): BreadcrumbItem[] => { + const pathSplit = (p = '') => p.split('/').filter(Boolean) + const current = pathSplit(currentRoute.path) + const resource = pathSplit(resourcePath) + + return resource.map( + (text, i) => + ({ + id: uuidv4(), + allowContextActions: true, + text, + to: { + path: '/' + [...current].splice(0, current.length - resource.length + i + 1).join('/'), + query: omit(currentRoute.query, 'fileId', 'page') // TODO: we need the correct fileId in the query. until we have that we must omit it because otherwise we would correct the path to the one of the (wrong) fileId. + }, + isStaticNav: false + } as BreadcrumbItem) + ) +} + +export const concatBreadcrumbs = (...items: BreadcrumbItem[]): BreadcrumbItem[] => { + const last = items.pop() + + return [ + ...items, + { + id: uuidv4(), + allowContextActions: last.allowContextActions, + text: last.text, + onClick: () => eventBus.publish('app.files.list.load'), + isTruncationPlaceholder: last.isTruncationPlaceholder, + isStaticNav: last.isStaticNav + } + ] +} diff --git a/packages/web-pkg/src/helpers/clipboardActions.ts b/packages/web-pkg/src/helpers/clipboardActions.ts new file mode 100644 index 00000000000..b1a1e184f3e --- /dev/null +++ b/packages/web-pkg/src/helpers/clipboardActions.ts @@ -0,0 +1,4 @@ +export abstract class ClipboardActions { + static readonly Cut = 'cut' + static readonly Copy = 'copy' +} diff --git a/packages/web-pkg/src/helpers/folderLink/index.ts b/packages/web-pkg/src/helpers/folderLink/index.ts new file mode 100644 index 00000000000..c9f6f047dc0 --- /dev/null +++ b/packages/web-pkg/src/helpers/folderLink/index.ts @@ -0,0 +1 @@ +export * from './types' diff --git a/packages/web-pkg/src/helpers/folderLink/types.ts b/packages/web-pkg/src/helpers/folderLink/types.ts new file mode 100644 index 00000000000..fda2867cf18 --- /dev/null +++ b/packages/web-pkg/src/helpers/folderLink/types.ts @@ -0,0 +1,7 @@ +import { Resource } from 'web-client' + +export interface CreateTargetRouteOptions { + path: string + fileId?: string | number + resource: Resource +} diff --git a/packages/web-pkg/src/helpers/permissions.ts b/packages/web-pkg/src/helpers/permissions.ts new file mode 100644 index 00000000000..3620fa84153 --- /dev/null +++ b/packages/web-pkg/src/helpers/permissions.ts @@ -0,0 +1,14 @@ +/** + * Asserts whether given resource can be moved + * @param {String} resource Resource which is to be moved + * @param {Object} parentPath Path of the parent folder of the resource + * @return {Boolean} can be moved + */ +export function canBeMoved(resource, parentPath) { + // TODO: Find a better way to prevent moving shared resources than by checking if the current folder is root + // TODO: Find a way to disable move action when shares are mounted in different folder then root + const isExternal = resource.isReceivedShare() || resource.isMounted() + const isMountedInRoot = parentPath === '' && isExternal + + return resource.canBeDeleted() && !isMountedInRoot +} diff --git a/packages/web-pkg/src/helpers/resource/ancestorMetaData.ts b/packages/web-pkg/src/helpers/resource/ancestorMetaData.ts new file mode 100644 index 00000000000..4ad109d6418 --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/ancestorMetaData.ts @@ -0,0 +1,9 @@ +export interface AncestorMetaDataValue { + id: string + shareTypes: number[] + parentFolderId: string + spaceId: string + path: string +} + +export type AncestorMetaData = Record diff --git a/packages/web-pkg/src/helpers/resource/conflictHandling/conflictDialog.ts b/packages/web-pkg/src/helpers/resource/conflictHandling/conflictDialog.ts new file mode 100644 index 00000000000..a08e9b9bcd4 --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/conflictHandling/conflictDialog.ts @@ -0,0 +1,155 @@ +import { join } from 'path' +import { Resource } from 'web-client' +import { ResolveConflict, ResolveStrategy } from '.' + +export interface FileConflict { + resource: Resource + strategy?: ResolveStrategy +} + +export class ConflictDialog { + /* eslint-disable no-useless-constructor */ + constructor( + protected createModal: (modal: object) => void, + protected hideModal: () => void, + protected showMessage: (data: object) => void, + protected showErrorMessage: (data: object) => void, + protected $gettext: (msg: string) => string, + protected $ngettext: (msgid: string, plural: string, n: number) => string, + protected $gettextInterpolate: ( + msg: string, + context: object, + disableHtmlEscaping?: boolean + ) => string + ) {} + + async resolveAllConflicts( + resourcesToMove: Resource[], + targetFolder: Resource, + targetFolderResources: Resource[] + ): Promise { + // Collect all conflicting resources + const allConflicts: FileConflict[] = [] + for (const resource of resourcesToMove) { + const targetFilePath = join(targetFolder.path, resource.name) + const exists = targetFolderResources.some((r) => r.path === targetFilePath) + if (exists) { + allConflicts.push({ resource, strategy: null }) + } + } + let count = 0 + let doForAllConflicts = false + let doForAllConflictsStrategy = null + const resolvedConflicts: FileConflict[] = [] + for (const conflict of allConflicts) { + // Resolve conflicts accordingly + if (doForAllConflicts) { + conflict.strategy = doForAllConflictsStrategy + resolvedConflicts.push(conflict) + continue + } + + // Resolve next conflict + const conflictsLeft = allConflicts.length - count + const result: ResolveConflict = await this.resolveFileExists( + conflict.resource, + conflictsLeft, + conflictsLeft === 1 + ) + conflict.strategy = result.strategy + resolvedConflicts.push(conflict) + count += 1 + + // User checked 'do for all x conflicts' + if (!result.doForAllConflicts) { + continue + } + doForAllConflicts = true + doForAllConflictsStrategy = result.strategy + } + return resolvedConflicts + } + + resolveFileExists( + resource: Resource, + conflictCount: number, + isSingleConflict: boolean, + suggestMerge = false, + separateSkipHandling = false // separate skip-handling between files and folders + ): Promise { + let translatedSkipLabel + + if (!separateSkipHandling) { + translatedSkipLabel = this.$gettext('Apply to all %{count} conflicts') + } else if (resource.isFolder) { + translatedSkipLabel = this.$gettext('Apply to all %{count} folders') + } else { + translatedSkipLabel = this.$gettext('Apply to all %{count} files') + } + + return new Promise((resolve) => { + let doForAllConflicts = false + const modal = { + variation: 'danger', + title: resource.isFolder + ? this.$gettext('Folder already exists') + : this.$gettext('File already exists'), + message: this.$gettextInterpolate( + resource.isFolder + ? this.$gettext('Folder with name "%{name}" already exists.') + : this.$gettext('File with name "%{name}" already exists.'), + { name: resource.name }, + true + ), + cancelText: this.$gettext('Skip'), + confirmText: this.$gettext('Keep both'), + buttonSecondaryText: suggestMerge ? this.$gettext('Merge') : this.$gettext('Replace'), + checkboxLabel: isSingleConflict + ? '' + : this.$gettextInterpolate(translatedSkipLabel, { count: conflictCount }, true), + onCheckboxValueChanged: (value) => { + doForAllConflicts = value + }, + onCancel: () => { + this.hideModal() + resolve({ strategy: ResolveStrategy.SKIP, doForAllConflicts } as ResolveConflict) + }, + onConfirmSecondary: () => { + this.hideModal() + const strategy = suggestMerge ? ResolveStrategy.MERGE : ResolveStrategy.REPLACE + resolve({ strategy, doForAllConflicts } as ResolveConflict) + }, + onConfirm: () => { + this.hideModal() + resolve({ strategy: ResolveStrategy.KEEP_BOTH, doForAllConflicts } as ResolveConflict) + } + } + this.createModal(modal) + }) + } + + resolveDoCopyInsteadOfMoveForSpaces(): Promise { + return new Promise((resolve) => { + const modal = { + variation: 'danger', + title: this.$gettext('Copy here?'), + customContent: `

${this.$gettext( + 'Moving files from one space to another is not possible. Do you want to copy instead?' + )}

${this.$gettext( + 'Note: Links and shares of the original file are not copied.' + )}

`, + cancelText: this.$gettext('Cancel'), + confirmText: this.$gettext('Copy here'), + onCancel: () => { + this.hideModal() + resolve(false) + }, + onConfirm: () => { + this.hideModal() + resolve(true) + } + } + this.createModal(modal) + }) + } +} diff --git a/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts b/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts new file mode 100644 index 00000000000..cf12cc935d8 --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/conflictHandling/conflictUtils.ts @@ -0,0 +1,16 @@ +import { extractNameWithoutExtension, Resource } from 'web-client/src/helpers' + +export const resolveFileNameDuplicate = (name, extension, existingFiles, iteration = 1) => { + let potentialName + if (extension.length === 0) { + potentialName = `${name} (${iteration})` + } else { + const nameWithoutExtension = extractNameWithoutExtension({ name, extension } as Resource) + potentialName = `${nameWithoutExtension} (${iteration}).${extension}` + } + const hasConflict = existingFiles.some((f) => f.name === potentialName) + if (!hasConflict) { + return potentialName + } + return resolveFileNameDuplicate(name, extension, existingFiles, iteration + 1) +} diff --git a/packages/web-pkg/src/helpers/resource/conflictHandling/index.ts b/packages/web-pkg/src/helpers/resource/conflictHandling/index.ts new file mode 100644 index 00000000000..d4254c788c1 --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/conflictHandling/index.ts @@ -0,0 +1,3 @@ +export * from './conflictDialog' +export * from './conflictUtils' +export * from './types' diff --git a/packages/web-pkg/src/helpers/resource/conflictHandling/types.ts b/packages/web-pkg/src/helpers/resource/conflictHandling/types.ts new file mode 100644 index 00000000000..c401f55710f --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/conflictHandling/types.ts @@ -0,0 +1,10 @@ +export enum ResolveStrategy { + SKIP, + REPLACE, + KEEP_BOTH, + MERGE +} +export interface ResolveConflict { + strategy: ResolveStrategy + doForAllConflicts: boolean +} diff --git a/packages/web-pkg/src/helpers/resource/index.ts b/packages/web-pkg/src/helpers/resource/index.ts new file mode 100644 index 00000000000..6770d9ed4c3 --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/index.ts @@ -0,0 +1,3 @@ +export * from './ancestorMetaData' +export * from './sameResource' +export * from './conflictHandling' diff --git a/packages/web-pkg/src/helpers/resource/sameResource.ts b/packages/web-pkg/src/helpers/resource/sameResource.ts new file mode 100644 index 00000000000..bb4d2fbb381 --- /dev/null +++ b/packages/web-pkg/src/helpers/resource/sameResource.ts @@ -0,0 +1,8 @@ +import { Resource } from 'web-client' + +export const isSameResource = (r1: Resource, r2: Resource): boolean => { + if (!r1 || !r2) { + return false + } + return r1.id === r2.id +} diff --git a/packages/web-pkg/src/helpers/resources.ts b/packages/web-pkg/src/helpers/resources.ts new file mode 100644 index 00000000000..900c40ea7a4 --- /dev/null +++ b/packages/web-pkg/src/helpers/resources.ts @@ -0,0 +1,398 @@ +import orderBy from 'lodash-es/orderBy' +import path, { basename, join } from 'path' +import { DateTime } from 'luxon' +import { DavProperty } from 'web-client/src/webdav/constants' +import { + LinkShareRoles, + PeopleShareRoles, + SharePermissions, + Share, + ShareStatus, + ShareTypes, + buildSpaceShare +} from 'web-client/src/helpers/share' +import { + buildWebDavSpacesPath, + extractDomSelector, + extractExtensionFromFile, + extractStorageId +} from 'web-client/src/helpers/resource' +import { Resource, SpaceResource, SHARE_JAIL_ID } from 'web-client/src/helpers' +import { urlJoin } from 'web-client/src/utils' + +export function renameResource(space: SpaceResource, resource: Resource, newPath: string) { + resource.name = basename(newPath) + resource.path = newPath + resource.webDavPath = join(space.webDavPath, newPath) + resource.extension = extractExtensionFromFile(resource) + return resource +} + +export function buildWebDavFilesPath(userId, path) { + return '/' + `files/${userId}/${path}`.split('/').filter(Boolean).join('/') +} + +export function buildWebDavFilesTrashPath(userId, path = '') { + return '/' + `trash-bin/${userId}/${path}`.split('/').filter(Boolean).join('/') +} + +export function isResourceTxtFileAlmostEmpty(resource: Resource): boolean { + const mimeType = resource.mimeType || '' + return mimeType.startsWith('text/') && (resource.size as number) < 30 +} + +/** + * Transforms given shares into a resource format and returns only their unique occurences + * @param {Array} shares Shares to be transformed into unique resources + * @param {Boolean} incomingShares Asserts whether the shares are incoming + * @param {Boolean} allowSharePermission Asserts whether the reshare permission is available + * @param {Boolean} hasShareJail Asserts whether the share jail is available backend side + * @param {Array} spaces A list of spaces the current user has access to + */ +export function aggregateResourceShares( + shares, + incomingShares = false, + allowSharePermission, + hasShareJail, + spaces = [] +): Resource[] { + shares.sort((a, b) => a.path.localeCompare(b.path)) + if (spaces.length) { + shares = addMatchingSpaceToShares(shares, spaces) + } + if (incomingShares) { + shares = addSharedWithToShares(shares) + return orderBy(shares, ['file_source', 'permissions'], ['asc', 'desc']).map((share) => { + const resource = buildSharedResource( + share, + incomingShares, + allowSharePermission, + hasShareJail + ) + resource.shareId = share.id + return resource + }) + } + + const resources = addSharedWithToShares(shares) + return resources.map((share) => + buildSharedResource(share, incomingShares, allowSharePermission, hasShareJail) + ) +} + +function addSharedWithToShares(shares) { + const resources = [] + let previousShare = null + for (const share of shares) { + if ( + previousShare?.storage_id === share.storage_id && + previousShare?.file_source === share.file_source + ) { + if (ShareTypes.containsAnyValue(ShareTypes.authenticated, [parseInt(share.share_type)])) { + if (share.stime > previousShare.stime) { + previousShare.stime = share.stime + } + previousShare.sharedWith.push({ + username: share.share_with, + name: share.share_with_displayname, + displayName: share.share_with_displayname, + avatar: undefined, + shareType: parseInt(share.share_type) + }) + } else if (parseInt(share.share_type) === ShareTypes.link.value) { + previousShare.sharedWith.push({ + name: share.name || share.token, + link: true, + shareType: parseInt(share.share_type) + }) + } + + continue + } + + if (ShareTypes.containsAnyValue(ShareTypes.authenticated, [parseInt(share.share_type)])) { + share.sharedWith = [ + { + username: share.share_with, + displayName: share.share_with_displayname, + name: share.share_with_displayname, + avatar: undefined, + shareType: parseInt(share.share_type) + } + ] + } else if (parseInt(share.share_type) === ShareTypes.link.value) { + share.sharedWith = [ + { + name: share.name || share.token, + link: true, + shareType: parseInt(share.share_type) + } + ] + } + + previousShare = share + resources.push(share) + } + return resources +} + +function addMatchingSpaceToShares(shares, spaces) { + const resources = [] + for (const share of shares) { + let matchingSpace + if (share.path === '/') { + const storageId = extractStorageId(share.item_source) + matchingSpace = spaces.find((s) => s.id === storageId && s.driveType === 'project') + } + resources.push({ ...share, matchingSpace }) + } + return resources +} + +export function buildSharedResource( + share, + incomingShares = false, + allowSharePermission = true, + hasShareJail = false +): Resource { + const isFolder = share.item_type === 'folder' + let resource: Resource = { + id: share.id, + fileId: share.item_source, + storageId: extractStorageId(share.item_source), + parentFolderId: share.file_parent, + type: share.item_type, + mimeType: share.mimetype, + isFolder, + sdate: DateTime.fromSeconds(parseInt(share.stime)).toRFC2822(), + indicators: [], + tags: [], + path: undefined, + webDavPath: undefined, + processing: share.processing || false + } + + if (incomingShares) { + resource.resourceOwner = { + username: share.uid_file_owner as string, + displayName: share.displayname_file_owner as string + } + resource.owner = [ + { + username: share.uid_owner as string, + displayName: share.displayname_owner as string, + avatar: undefined, + shareType: ShareTypes.user.value + } + ] + resource.sharedWith = share.sharedWith || [] + resource.status = parseInt(share.state) + resource.name = path.basename(share.file_target) + if (hasShareJail) { + // FIXME, HACK 1: path needs to be '/' because the share has it's own webdav endpoint (we access it's root). should ideally be removed backend side. + // FIXME, HACK 2: webDavPath points to `files//Shares/xyz` but now needs to point to a shares webdav root. should ideally be changed backend side. + resource.path = '/' + resource.webDavPath = buildWebDavSpacesPath([SHARE_JAIL_ID, resource.id].join('!'), '/') + } else { + resource.path = share.file_target + resource.webDavPath = buildWebDavFilesPath(share.share_with, share.file_target) + } + resource.canDownload = () => parseInt(share.state) === ShareStatus.accepted + resource.canShare = () => SharePermissions.share.enabled(share.permissions) + resource.canRename = () => parseInt(share.state) === ShareStatus.accepted + resource.canBeDeleted = () => SharePermissions.delete.enabled(share.permissions) + resource.canEditTags = () => + parseInt(share.state) === ShareStatus.accepted && + SharePermissions.update.enabled(share.permissions) + } else { + resource.sharedWith = share.sharedWith || [] + resource.shareOwner = share.uid_owner + resource.shareOwnerDisplayname = share.displayname_owner + resource.name = path.basename(share.path) + resource.path = share.path + resource.webDavPath = hasShareJail + ? buildWebDavSpacesPath(resource.storageId, share.path) + : buildWebDavFilesPath(share.uid_owner, share.path) + resource.canDownload = () => true + resource.canShare = () => true + resource.canRename = () => true + resource.canBeDeleted = () => true + resource.canEditTags = () => true + } + + resource.extension = extractExtensionFromFile(resource) + resource.isReceivedShare = () => incomingShares + resource.canUpload = () => SharePermissions.create.enabled(share.permissions) + resource.canCreate = () => SharePermissions.create.enabled(share.permissions) + resource.isMounted = () => false + resource.share = buildShare(share, resource, allowSharePermission) + resource.canDeny = () => SharePermissions.denied.enabled(share.permissions) + resource.getDomSelector = () => extractDomSelector(share.id) + + if (share.matchingSpace) { + resource = { ...resource, ...share.matchingSpace } + } + + return resource +} + +export function buildShare(s, file, allowSharePermission): Share { + if (parseInt(s.share_type) === ShareTypes.link.value) { + return _buildLink(s) + } + if ([ShareTypes.spaceUser.value, ShareTypes.spaceGroup.value].includes(parseInt(s.share_type))) { + return buildSpaceShare(s, file) + } + + return buildCollaboratorShare(s, file, allowSharePermission) +} + +function _buildLink(link): Share { + let description = '' + const permissions = parseInt(link.permissions) + + const role = LinkShareRoles.getByBitmask(permissions, link.item_type === 'folder') + if (role) { + description = role.label + } + + const quicklinkOc10 = ((): boolean => { + if (typeof link.attributes !== 'string') { + return false + } + + return ( + JSON.parse(link.attributes || '[]').find((attr) => attr.key === 'isQuickLink')?.enabled === + 'true' + ) + })() + const quicklinkOcis = link.quicklink === 'true' + const quicklink = quicklinkOc10 || quicklinkOcis + + return { + shareType: parseInt(link.share_type), + id: link.id, + token: link.token as string, + url: link.url, + path: link.path, + permissions, + description, + quicklink, + stime: link.stime, + name: typeof link.name === 'string' ? link.name : (link.token as string), + password: !!(link.share_with && link.share_with_displayname), + expiration: + typeof link.expiration === 'string' + ? DateTime.fromFormat(link.expiration, 'yyyy-MM-dd HH:mm:ss').toFormat('yyyy-MM-dd') + : null, + itemSource: link.item_source, + file: { + parent: link.file_parent, + source: link.file_source, + target: link.file_target + } + } +} + +function _fixAdditionalInfo(data) { + if (typeof data !== 'string') { + return null + } + return data +} + +export function buildCollaboratorShare(s, file, allowSharePermission): Share { + const share: Share = { + shareType: parseInt(s.share_type), + id: s.id, + itemSource: s.item_source, + file: { + parent: s.file_parent, + source: s.file_source, + target: s.file_target + } + } + if ( + ShareTypes.containsAnyValue( + [ShareTypes.user, ShareTypes.remote, ShareTypes.group, ShareTypes.guest], + [share.shareType] + ) + ) { + // FIXME: SDK is returning empty object for additional info when empty + share.collaborator = { + name: s.share_with, + displayName: s.share_with_displayname, + additionalInfo: _fixAdditionalInfo(s.share_with_additional_info) + } + share.owner = { + name: s.uid_owner, + displayName: s.displayname_owner, + additionalInfo: _fixAdditionalInfo(s.additional_info_owner) + } + share.fileOwner = { + name: s.uid_file_owner, + displayName: s.displayname_file_owner, + additionalInfo: _fixAdditionalInfo(s.additional_info_file_owner) + } + share.stime = s.stime + share.permissions = parseInt(s.permissions) + share.customPermissions = SharePermissions.bitmaskToPermissions(s.permissions) + share.role = PeopleShareRoles.getByBitmask( + parseInt(s.permissions), + file.isFolder || file.type === 'folder', + allowSharePermission + ) + // share.email = 'foo@djungle.com' // hm, where do we get the mail from? share_with_additional_info:Object? + } + + // expiration:Object if unset, or string "2019-04-24 00:00:00" + if (typeof s.expiration === 'string' || s.expiration instanceof String) { + share.expires = new Date(s.expiration) + } + share.path = s.path + + return share +} + +export function buildDeletedResource(resource): Resource { + const isFolder = resource.type === 'dir' || resource.type === 'folder' + const fullName = resource.fileInfo[DavProperty.TrashbinOriginalFilename] + const extension = extractExtensionFromFile({ name: fullName, type: resource.type } as Resource) + const id = path.basename(resource.name) + return { + type: isFolder ? 'folder' : resource.type, + isFolder, + ddate: resource.fileInfo[DavProperty.TrashbinDeletedDate], + name: path.basename(fullName), + extension, + path: urlJoin(resource.fileInfo[DavProperty.TrashbinOriginalLocation], { leadingSlash: true }), + id, + parentFolderId: resource.fileInfo[DavProperty.FileParent], + indicators: [], + webDavPath: '', + canUpload: () => false, + canDownload: () => false, + canBeDeleted: () => { + /** FIXME: once https://github.com/owncloud/ocis/issues/3339 gets implemented, + * we want to add a check if the permission is set. + * We might to be careful and do an early return true if DavProperty.Permissions is not set + * as oc10 does not support it. + **/ + return true + }, + canBeRestored: function () { + /** FIXME: once https://github.com/owncloud/ocis/issues/3339 gets implemented, + * we want to add a check if the permission is set. + * We might to be careful and do an early return true if DavProperty.Permissions is not set + * as oc10 does not support it. + **/ + return true + }, + canRename: () => false, + canShare: () => false, + canCreate: () => false, + isMounted: () => false, + isReceivedShare: () => false, + getDomSelector: () => extractDomSelector(id) + } +} diff --git a/packages/web-pkg/src/helpers/share/index.ts b/packages/web-pkg/src/helpers/share/index.ts new file mode 100644 index 00000000000..81caa3b484c --- /dev/null +++ b/packages/web-pkg/src/helpers/share/index.ts @@ -0,0 +1,2 @@ +export * from './link' +export * from './sharedAncestorRoute' diff --git a/packages/web-pkg/src/helpers/share/link.ts b/packages/web-pkg/src/helpers/share/link.ts new file mode 100644 index 00000000000..6f912eca3f9 --- /dev/null +++ b/packages/web-pkg/src/helpers/share/link.ts @@ -0,0 +1,93 @@ +import { DateTime } from 'luxon' +import { + LinkShareRoles, + Share, + linkRoleInternalFolder, + linkRoleViewerFolder +} from 'web-client/src/helpers/share' +import { Store } from 'vuex' +import { ClientService } from 'web-pkg/src/services' +import { useClipboard } from '@vueuse/core' +import { Ability } from 'web-client/src/helpers/resource/types' +import { Resource } from 'web-client' +import { Language } from 'vue3-gettext' + +export interface CreateQuicklink { + clientService: ClientService + language: Language + store: Store + storageId?: any + resource: Resource + password?: string + ability: Ability +} + +export const createQuicklink = async (args: CreateQuicklink): Promise => { + const { clientService, resource, store, password, language, ability } = args + const { $gettext } = language + + const canCreatePublicLink = ability.can('create-all', 'PublicLink') + const allowResharing = store.state.user.capabilities.files_sharing?.resharing + const capabilitiesRoleName = + store.state.user.capabilities.files_sharing?.quick_link?.default_role || + linkRoleViewerFolder.name + const canEdit = store.state.user.capabilities.files_sharing?.public?.can_edit || false + const canContribute = store.state.user.capabilities.files_sharing?.public?.can_contribute || false + const alias = store.state.user.capabilities.files_sharing?.public?.alias + const roleName = !canCreatePublicLink + ? linkRoleInternalFolder.name + : capabilitiesRoleName || linkRoleViewerFolder.name + const permissions = LinkShareRoles.getByName( + roleName, + resource.isFolder, + canEdit, + canContribute, + alias + ).bitmask(allowResharing) + const params: { [key: string]: unknown } = { + name: $gettext('Link'), + permissions: permissions.toString(), + quicklink: true + } + + if (password) { + params.password = password + } + + const expirationDate = store.state.user.capabilities.files_sharing.public.expire_date + + if (expirationDate.enforced) { + params.expireDate = DateTime.now() + .plus({ days: parseInt(expirationDate.days, 10) }) + .endOf('day') + .toISO() + } + + // needs check for enforced password for default role (viewer?) + // and concept to what happens if it is enforced + + params.spaceRef = resource.fileId || resource.id + + try { + const link = await store.dispatch('Files/addLink', { + path: resource.path, + client: clientService.owncloudSdk, + params, + storageId: resource.fileId || resource.id + }) + const { copy } = useClipboard({ legacy: true }) + copy(link.url) + + await store.dispatch('showMessage', { + title: $gettext('The link has been copied to your clipboard.') + }) + + return link + } catch (e) { + console.error(e) + await store.dispatch('showErrorMessage', { + title: $gettext('Copy link failed'), + error: e + }) + } +} diff --git a/packages/web-pkg/src/helpers/share/sharedAncestorRoute.ts b/packages/web-pkg/src/helpers/share/sharedAncestorRoute.ts new file mode 100644 index 00000000000..edfb8628d26 --- /dev/null +++ b/packages/web-pkg/src/helpers/share/sharedAncestorRoute.ts @@ -0,0 +1,45 @@ +import { buildShareSpaceResource, Resource, SpaceResource } from 'web-client/src/helpers' +import { basename } from 'path' +import { configurationManager } from 'web-pkg' +import { createLocationSpaces } from 'web-pkg/src/router' +import { createFileRouteOptions } from 'web-pkg/src/helpers/router' +import { RouteLocationNamedRaw } from 'vue-router' +import { AncestorMetaDataValue } from 'web-pkg/src/helpers/resource/ancestorMetaData' + +export const getSharedAncestorRoute = ({ + resource, + sharedAncestor, + matchingSpace +}: { + resource: Resource + sharedAncestor: AncestorMetaDataValue + matchingSpace: SpaceResource +}): RouteLocationNamedRaw => { + if (resource.shareId) { + if (resource.path === '') { + return {} + } + const space = buildShareSpaceResource({ + shareId: resource.shareId, + shareName: matchingSpace?.name || basename(resource.shareRoot), + serverUrl: configurationManager.serverUrl + }) + return createLocationSpaces( + 'files-spaces-generic', + createFileRouteOptions(space, { + path: sharedAncestor.path, + fileId: sharedAncestor.id + }) + ) + } + if (!matchingSpace) { + return {} + } + return createLocationSpaces( + 'files-spaces-generic', + createFileRouteOptions(matchingSpace, { + path: sharedAncestor.path, + fileId: sharedAncestor.id + }) + ) +} diff --git a/packages/web-pkg/src/helpers/share/triggerShareAction.ts b/packages/web-pkg/src/helpers/share/triggerShareAction.ts new file mode 100644 index 00000000000..be537a83e28 --- /dev/null +++ b/packages/web-pkg/src/helpers/share/triggerShareAction.ts @@ -0,0 +1,44 @@ +import { aggregateResourceShares } from '../resources' +import { ShareStatus } from 'web-client/src/helpers/share/status' +import { HttpError } from 'web-pkg/src/errors' + +export async function triggerShareAction(resource, status, hasReSharing, hasShareJail, $client) { + const method = _getRequestMethod(status) + if (!method) { + throw new Error('invalid new share status') + } + + // exec share action + let response = await $client.requests.ocs({ + service: 'apps/files_sharing', + action: `api/v1/shares/pending/${resource.share.id}`, + method + }) + + // exit on failure + if (response.status !== 200) { + throw new HttpError(response.statusText, response) + } + + // get updated share from response and transform & return it + if (parseInt(response.headers.get('content-length')) > 0) { + response = await response.json() + if (response.ocs.data.length > 0) { + const share = response.ocs.data[0] + return aggregateResourceShares([share], true, hasReSharing, hasShareJail)[0] + } + } + + return null +} + +function _getRequestMethod(status) { + switch (status) { + case ShareStatus.accepted: + return 'POST' + case ShareStatus.declined: + return 'DELETE' + default: + return null + } +} diff --git a/packages/web-pkg/src/helpers/statusIndicators.ts b/packages/web-pkg/src/helpers/statusIndicators.ts new file mode 100644 index 00000000000..1aba95eb79a --- /dev/null +++ b/packages/web-pkg/src/helpers/statusIndicators.ts @@ -0,0 +1,88 @@ +import { ShareTypes } from 'web-client/src/helpers/share' +import { eventBus } from 'web-pkg' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' +import { createLocationShares } from 'web-pkg/src/router' +import { Resource } from 'web-client' +import { AncestorMetaData } from 'web-pkg/src/helpers/resource/ancestorMetaData' + +// dummy to trick gettext string extraction into recognizing strings +const $gettext = (str) => { + return str +} + +const isUserShare = (shareTypes) => { + return ShareTypes.containsAnyValue(ShareTypes.authenticated, shareTypes ?? []) +} + +const isLinkShare = (shareTypes) => { + return ShareTypes.containsAnyValue(ShareTypes.unauthenticated, shareTypes ?? []) +} + +const shareUserIconDescribedBy = ({ isDirect }) => { + return isDirect + ? $gettext('This item is directly shared with others.') + : $gettext('This item is shared with others through one of the parent folders.') +} + +const shareLinkDescribedBy = ({ isDirect }) => { + return isDirect + ? $gettext('This item is directly shared via links.') + : $gettext('This item is shared via links through one of the parent folders.') +} + +const getUserIndicator = ({ resource, isDirect, isIncoming = false }) => { + return { + id: `files-sharing-${resource.getDomSelector()}`, + accessibleDescription: shareUserIconDescribedBy({ isDirect }), + label: isIncoming ? $gettext('Shared with you') : $gettext('Show invited people'), + icon: 'group', + target: 'sharing', + type: isDirect ? 'user-direct' : 'user-indirect', + handler: (resource, panel, $router) => { + if (isIncoming) { + $router.push(createLocationShares('files-shares-with-me')) + return + } + eventBus.publish(SideBarEventTopics.openWithPanel, `${panel}#peopleShares`) + } + } +} + +const getLinkIndicator = ({ resource, isDirect }) => { + return { + id: `file-link-${resource.getDomSelector()}`, + accessibleDescription: shareLinkDescribedBy({ isDirect }), + label: $gettext('Show links'), + icon: 'link', + target: 'sharing', + type: isDirect ? 'link-direct' : 'link-indirect', + handler: (resource, panel) => { + eventBus.publish(SideBarEventTopics.openWithPanel, `${panel}#linkShares`) + } + } +} + +export const getIndicators = ({ + resource, + ancestorMetaData +}: { + resource: Resource + ancestorMetaData: AncestorMetaData +}) => { + const indicators = [] + const parentShareTypes = Object.values(ancestorMetaData).reduce((acc: any, data: any) => { + acc.push(...(data.shareTypes || [])) + return acc + }, []) + const isDirectUserShare = isUserShare(resource.shareTypes) + if (isDirectUserShare || isUserShare(parentShareTypes)) { + indicators.push(getUserIndicator({ resource, isDirect: isDirectUserShare })) + } else if (resource.isReceivedShare()) { + indicators.push(getUserIndicator({ resource, isDirect: false, isIncoming: true })) + } + const isDirectLinkShare = isLinkShare(resource.shareTypes) + if (isDirectLinkShare || isLinkShare(parentShareTypes)) { + indicators.push(getLinkIndicator({ resource, isDirect: isDirectLinkShare })) + } + return indicators +} diff --git a/packages/web-pkg/src/helpers/store.ts b/packages/web-pkg/src/helpers/store.ts new file mode 100644 index 00000000000..45b1abd0bcf --- /dev/null +++ b/packages/web-pkg/src/helpers/store.ts @@ -0,0 +1,13 @@ +/** + * Takes an object from state and creates a copy of it with only the values (no watchers, etc.) + * Editing the copied object does not result in errors due to modifying the state. + * The copied object is still reactive. + * @param {Object} state Object in the state to be copied + * @return {Object} Copied object + */ +export function cloneStateObject(state: unknown): any { + if (state === undefined) { + throw new Error('cloneStateObject: cannot clone "undefined"') + } + return JSON.parse(JSON.stringify(state)) +} diff --git a/packages/web-pkg/src/helpers/ui/filesList.ts b/packages/web-pkg/src/helpers/ui/filesList.ts new file mode 100644 index 00000000000..87538d24e91 --- /dev/null +++ b/packages/web-pkg/src/helpers/ui/filesList.ts @@ -0,0 +1,12 @@ +export const accentuateItem = (id: string, clearTimeout = 3500): void => { + const item = document.querySelectorAll(`[data-item-id='${id}']`)[0] + + if (!item) { + return + } + + item.classList.add('oc-table-accentuated') + setTimeout(() => { + item.classList.remove('oc-table-accentuated') + }, clearTimeout) +} diff --git a/packages/web-pkg/src/helpers/ui/index.ts b/packages/web-pkg/src/helpers/ui/index.ts new file mode 100644 index 00000000000..19ae8714842 --- /dev/null +++ b/packages/web-pkg/src/helpers/ui/index.ts @@ -0,0 +1,3 @@ +export * as fileList from './filesList' +export * as resourceTable from './resourceTable' +export * as resourceTiles from './resourceTiles' diff --git a/packages/web-pkg/src/helpers/ui/resourceTable.ts b/packages/web-pkg/src/helpers/ui/resourceTable.ts new file mode 100644 index 00000000000..731810954be --- /dev/null +++ b/packages/web-pkg/src/helpers/ui/resourceTable.ts @@ -0,0 +1,62 @@ +import { SortDir, SortField } from 'web-pkg/src/composables' + +export const determineSortFields = (firstResource): SortField[] => { + if (!firstResource) { + return [] + } + + return [ + { + name: 'name', + sortable: true, + sortDir: SortDir.Asc + }, + { + name: 'size', + sortable: true, + sortDir: SortDir.Desc + }, + { + name: 'sharedWith', + sortable: (sharedWith) => { + if (sharedWith.length > 0) { + // Ensure the sharees are always sorted and that users + // take precedence over groups. Then return a string with + // all elements to ensure shares with multiple shares do + // not appear mixed within others with a single one + return sharedWith + .sort((a, b) => { + if (a.shareType !== b.shareType) { + return a.shareType < b.shareType ? -1 : 1 + } + return a.displayName < b.displayName ? -1 : 1 + }) + .map((e) => e.displayName) + .join() + } + return false + }, + sortDir: SortDir.Asc + }, + { + name: 'owner', + sortable: 'displayName', + sortDir: SortDir.Asc + }, + { + name: 'mdate', + sortable: (date) => new Date(date).valueOf(), + sortDir: SortDir.Desc + }, + { + name: 'sdate', + sortable: (date) => new Date(date).valueOf(), + sortDir: SortDir.Desc + }, + { + name: 'ddate', + sortable: (date) => new Date(date).valueOf(), + sortDir: SortDir.Desc + } + ].filter((field) => Object.prototype.hasOwnProperty.call(firstResource, field.name)) +} diff --git a/packages/web-pkg/src/helpers/ui/resourceTiles.ts b/packages/web-pkg/src/helpers/ui/resourceTiles.ts new file mode 100644 index 00000000000..e2191fe393e --- /dev/null +++ b/packages/web-pkg/src/helpers/ui/resourceTiles.ts @@ -0,0 +1,76 @@ +import { SortDir, SortField } from 'web-pkg/src/composables' + +// just a dummy function to trick gettext tools +function $gettext(msg) { + return msg +} + +export const sortFields: SortField[] = [ + { + label: $gettext('A-Z'), + name: 'name', + sortable: true, + sortDir: SortDir.Asc + }, + { + label: $gettext('Z-A'), + name: 'name', + sortable: true, + sortDir: SortDir.Desc + }, + { + label: $gettext('Newest'), + name: 'mdate', + sortable: (date) => new Date(date).valueOf(), + sortDir: SortDir.Desc + }, + { + label: $gettext('Oldest'), + name: 'mdate', + sortable: (date) => new Date(date).valueOf(), + sortDir: SortDir.Asc + }, + { + label: $gettext('Largest'), + name: 'size', + sortable: true, + sortDir: SortDir.Desc + }, + { + label: $gettext('Smallest'), + name: 'size', + sortable: true, + sortDir: SortDir.Asc + }, + { + label: $gettext('Remaining quota'), + name: 'remainingQuota', + prop: 'spaceQuota.remaining', + sortable: true, + sortDir: SortDir.Desc + }, + { + label: $gettext('Total quota'), + name: 'totalQuota', + prop: 'spaceQuota.total', + sortable: true, + sortDir: SortDir.Desc + }, + { + label: $gettext('Used quota'), + name: 'usedQuota', + prop: 'spaceQuota.used', + sortable: true, + sortDir: SortDir.Desc + } +] + +export const determineSortFields = (firstResource): SortField[] => { + if (!firstResource) { + return [] + } + + return sortFields.filter((field) => + Object.prototype.hasOwnProperty.call(firstResource, field.name) + ) +} diff --git a/packages/web-pkg/src/quickActions.ts b/packages/web-pkg/src/quickActions.ts new file mode 100644 index 00000000000..a2e01d0d08a --- /dev/null +++ b/packages/web-pkg/src/quickActions.ts @@ -0,0 +1,106 @@ +import { createQuicklink } from './helpers/share' +import { eventBus } from 'web-pkg/src/services/eventBus' +import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' +import { Resource } from 'web-client' +import { Language } from 'vue3-gettext' +import { ClientService } from 'web-pkg' +import { Ability } from 'web-client/src/helpers/resource/types' +import { Store } from 'vuex' + +export function canShare(item, store) { + const { capabilities } = store.state.user + if (!capabilities.files_sharing || !capabilities.files_sharing.api_enabled) { + return false + } + if (item.isReceivedShare() && !capabilities.files_sharing.resharing) { + return false + } + return item.canShare() +} + +export function showQuickLinkPasswordModal({ $gettext, store }, onConfirm) { + const modal = { + variation: 'passive', + title: $gettext('Set password'), + cancelText: $gettext('Cancel'), + confirmText: $gettext('Set'), + hasInput: true, + inputDescription: $gettext('Passwords for links are required.'), + inputLabel: $gettext('Password'), + inputType: 'password', + onCancel: () => store.dispatch('hideModal'), + onConfirm: async (password) => { + if (!password || password.trim() === '') { + store.dispatch('showErrorMessage', { + title: $gettext('Password cannot be empty') + }) + } else { + await store.dispatch('hideModal') + onConfirm(password) + } + }, + onInput: (password) => { + if (password.trim() === '') { + return store.dispatch('setModalInputErrorMessage', $gettext('Password cannot be empty')) + } + return store.dispatch('setModalInputErrorMessage', null) + } + } + + return store.dispatch('createModal', modal) +} + +interface QuickLinkContext { + ability: Ability + clientService: ClientService + item: Resource + language: Language + store: Store +} + +export default { + collaborators: { + id: 'collaborators', + label: ($gettext) => $gettext('Add people'), + icon: 'user-add', + iconFillType: undefined, + handler: () => eventBus.publish(SideBarEventTopics.openWithPanel, 'sharing#peopleShares'), + displayed: canShare + }, + quicklink: { + id: 'quicklink', + label: ($gettext) => $gettext('Copy link'), + icon: 'link', + iconFillType: undefined, + handler: async ({ ability, clientService, item, language, store }: QuickLinkContext) => { + const passwordEnforced = + store.getters.capabilities?.files_sharing?.public?.password?.enforced_for?.read_only === + true + + if (passwordEnforced) { + return showQuickLinkPasswordModal( + { store, $gettext: language.$gettext }, + async (password) => { + await createQuicklink({ + ability, + clientService, + language, + password, + resource: item, + store + }) + } + ) + } + + await createQuicklink({ + ability, + clientService, + language, + resource: item, + store + }) + }, + displayed: canShare + } +} // FIXME: fix type, then add: satisfies ApplicationQuickActions diff --git a/packages/web-pkg/src/router/common.ts b/packages/web-pkg/src/router/common.ts new file mode 100644 index 00000000000..b0e18e25daa --- /dev/null +++ b/packages/web-pkg/src/router/common.ts @@ -0,0 +1,50 @@ +import { RouteComponents } from './router' +import { RouteLocationNamedRaw, RouteRecordRaw } from 'vue-router' +import { createLocation, $gettext, isLocationActiveDirector } from './utils' + +type commonTypes = 'files-common-favorites' | 'files-common-search' + +export const createLocationCommon = (name: commonTypes, location = {}): RouteLocationNamedRaw => + createLocation(name, location) + +export const locationFavorites = createLocationCommon('files-common-favorites') +export const locationSearch = createLocationCommon('files-common-search') + +export const isLocationCommonActive = isLocationActiveDirector( + locationFavorites, + locationSearch +) + +export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ + { + path: '/search', + component: components.App, + children: [ + { + name: locationSearch.name, + path: 'list/:page?', + component: components.SearchResults, + meta: { + authContext: 'user', + title: $gettext('Search results'), + contextQueryItems: ['term', 'provider', 'q_tags', 'q_fullText', 'scope', 'useScope'] + } + } + ] + }, + { + path: '/favorites', + component: components.App, + children: [ + { + name: locationFavorites.name, + path: '', + component: components.Favorites, + meta: { + authContext: 'user', + title: $gettext('Favorite files') + } + } + ] + } +] diff --git a/packages/web-pkg/src/router/deprecated.ts b/packages/web-pkg/src/router/deprecated.ts new file mode 100644 index 00000000000..e2380a52ca0 --- /dev/null +++ b/packages/web-pkg/src/router/deprecated.ts @@ -0,0 +1,134 @@ +import { + RouteRecordRaw, + RouteLocationNamedRaw, + RouteMeta, + Router, + RouteLocationPathRaw, + RouteLocationRaw +} from 'vue-router' +import { createLocationSpaces } from './spaces' +import { createLocationShares } from './shares' +import { createLocationCommon } from './common' +import { createLocationPublic } from './public' +import { isLocationActive as isLocationActiveNoCompat } from './utils' +import { createLocationTrash } from './trash' +import { urlJoin } from 'web-client/src/utils' + +/** + * all route configs created by buildRoutes are deprecated, + * this helper wraps a route config and warns the user that it will go away and redirect to the new location. + * + * @param routeConfig + */ +const deprecatedRedirect = (routeConfig: { + path: string + meta?: RouteMeta + redirect: (to: RouteLocationRaw) => Partial +}): RouteRecordRaw => { + return { + meta: { ...routeConfig.meta, authContext: 'anonymous' }, // authContext belongs to the redirect target, not to the redirect itself. + path: routeConfig.path, + redirect: (to) => { + const location = routeConfig.redirect(to) + + console.warn( + `route "${routeConfig.path}" is deprecated, use "${ + String(location.path) || String(location.name) + }" instead.` + ) + + return location + } + } +} + +/** + * listed routes only exist to keep backwards compatibility intact, + * all routes written in a flat syntax to keep them readable. + */ +export const buildRoutes = (): RouteLocationNamedRaw[] => + [ + { + path: '/list', + redirect: (to) => + createLocationSpaces('files-spaces-generic', { + ...to, + params: { ...to.params, driveAliasAndItem: 'personal/home' } + }) + }, + { + path: '/list/all/:item(.*)', + redirect: (to) => + createLocationSpaces('files-spaces-generic', { + ...to, + params: { + ...to.params, + driveAliasAndItem: urlJoin('personal/home', to.params.item, { leadingSlash: false }) + } + }) + }, + { + path: '/list/favorites', + redirect: (to) => createLocationCommon('files-common-favorites', to) + }, + { + path: '/list/shared-with-me', + redirect: (to) => createLocationShares('files-shares-with-me', to) + }, + { + path: '/list/shared-with-others', + redirect: (to) => createLocationShares('files-shares-with-others', to) + }, + { + path: '/list/shared-via-link', + redirect: (to) => createLocationShares('files-shares-via-link', to) + }, + { + path: '/trash-bin', + redirect: (to) => createLocationTrash('files-trash-generic', to) + }, + { + path: '/public/list/:item(.*)', + redirect: (to) => createLocationPublic('files-public-link', to) + }, + { + path: '/private-link/:fileId', + redirect: (to) => ({ name: 'resolvePrivateLink', params: { fileId: to.params.fileId } }) + }, + { + path: '/public-link/:token', + redirect: (to) => ({ name: 'resolvePublicLink', params: { token: to.params.token } }) + } + ].map(deprecatedRedirect) + +/** + * same as utils.isLocationActive with the difference that it remaps old route names to new ones and warns + * @param router + * @param comparatives + */ +export const isLocationActive = ( + router: Router, + ...comparatives: [RouteLocationNamedRaw, ...RouteLocationNamedRaw[]] +): boolean => { + const [first, ...rest] = comparatives.map((c) => { + const newName = { + 'files-personal': createLocationSpaces('files-spaces-generic').name, + 'files-favorites': createLocationCommon('files-common-favorites').name, + 'files-shared-with-others': createLocationShares('files-shares-with-others').name, + 'files-shared-with-me': createLocationShares('files-shares-with-me').name, + 'files-trashbin ': createLocationTrash('files-trash-generic').name, + 'files-public-list': createLocationPublic('files-public-link').name + }[c.name] + + if (newName) { + console.warn(`route name "${name}" is deprecated, use "${newName}" instead.`) + } + + return { + ...c, + ...(!!newName && { name: newName }) + } + }) + + return isLocationActiveNoCompat(router, first, ...rest) +} diff --git a/packages/web-pkg/src/router/index.ts b/packages/web-pkg/src/router/index.ts new file mode 100644 index 00000000000..f4138f78516 --- /dev/null +++ b/packages/web-pkg/src/router/index.ts @@ -0,0 +1,62 @@ +import { RouteLocationNamedRaw } from 'vue-router' + +import { + buildRoutes as buildCommonRoutes, + isLocationCommonActive, + createLocationCommon +} from './common' +import { buildRoutes as buildDeprecatedRoutes, isLocationActive } from './deprecated' +import { + buildRoutes as buildPublicRoutes, + createLocationPublic, + isLocationPublicActive +} from './public' +import { RouteComponents } from './router' +import { + buildRoutes as buildSharesRoutes, + isLocationSharesActive, + createLocationShares +} from './shares' +import { + buildRoutes as buildSpacesRoutes, + isLocationSpacesActive, + createLocationSpaces +} from './spaces' +import { + buildRoutes as buildTrashRoutes, + isLocationTrashActive, + createLocationTrash +} from './trash' +import type { ActiveRouteDirectorFunc } from './utils' + +const ROOT_ROUTE = { + name: 'root', + path: '/', + redirect: (to) => createLocationSpaces('files-spaces-generic', to) +} + +const buildRoutes = (components: RouteComponents): RouteLocationNamedRaw[] => [ + ROOT_ROUTE, + ...buildCommonRoutes(components), + ...buildSharesRoutes(components), + ...buildPublicRoutes(components), + ...buildSpacesRoutes(components), + ...buildTrashRoutes(components), + ...buildDeprecatedRoutes() +] + +export { + createLocationCommon, + createLocationShares, + createLocationSpaces, + createLocationPublic, + isLocationCommonActive, + isLocationSharesActive, + isLocationSpacesActive, + isLocationPublicActive, + isLocationActive, + isLocationTrashActive, + createLocationTrash, + buildRoutes, + ActiveRouteDirectorFunc +} diff --git a/packages/web-pkg/src/router/public.ts b/packages/web-pkg/src/router/public.ts new file mode 100644 index 00000000000..911537f1e18 --- /dev/null +++ b/packages/web-pkg/src/router/public.ts @@ -0,0 +1,55 @@ +import { RouteComponents } from './router' +import { RouteLocationNamedRaw, RouteRecordRaw } from 'vue-router' +import { createLocation, isLocationActiveDirector, $gettext } from './utils' + +type shareTypes = 'files-public-link' | 'files-public-upload' + +export const createLocationPublic = (name: shareTypes, location = {}): RouteLocationNamedRaw => + createLocation(name, location) + +export const locationPublicLink = createLocationPublic('files-public-link') +export const locationPublicUpload = createLocationPublic('files-public-upload') + +export const isLocationPublicActive = isLocationActiveDirector( + locationPublicLink, + locationPublicUpload +) + +export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ + { + path: '/link', + component: components.App, + meta: { + auth: false + }, + children: [ + { + name: locationPublicLink.name, + path: ':driveAliasAndItem(.*)?', + component: components.Spaces.DriveResolver, + meta: { + authContext: 'publicLink', + patchCleanPath: true + } + } + ] + }, + { + path: '/upload', + component: components.App, + meta: { + auth: false + }, + children: [ + { + name: locationPublicUpload.name, + path: ':token?', + component: components.FilesDrop, + meta: { + authContext: 'publicLink', + title: $gettext('Public file upload') + } + } + ] + } +] diff --git a/packages/web-pkg/src/router/router.ts b/packages/web-pkg/src/router/router.ts new file mode 100644 index 00000000000..d873be9a035 --- /dev/null +++ b/packages/web-pkg/src/router/router.ts @@ -0,0 +1,29 @@ +import { defineComponent } from 'vue' + +/** + * we need to inject the vue files into the route builders, + * this is because we also import the provided helpers from other js|ts files + * like mixins, rollup seems to have a problem to import files which contain vue file imports + * into js files which then again get imported by other vue files... + */ + +type Component = ReturnType + +export interface RouteComponents { + App: Component + Favorites: Component + FilesDrop: Component + SearchResults: Component + Shares: { + SharedWithMe: Component + SharedWithOthers: Component + SharedViaLink: Component + } + Spaces: { + DriveResolver: Component + Projects: Component + } + Trash: { + Overview: Component + } +} diff --git a/packages/web-pkg/src/router/shares.ts b/packages/web-pkg/src/router/shares.ts new file mode 100644 index 00000000000..0d84720ff58 --- /dev/null +++ b/packages/web-pkg/src/router/shares.ts @@ -0,0 +1,61 @@ +import { RouteComponents } from './router' +import { RouteLocationNamedRaw, RouteRecordRaw } from 'vue-router' +import { createLocation, isLocationActiveDirector, $gettext } from './utils' + +type shareTypes = + | 'files-shares' + | 'files-shares-with-me' + | 'files-shares-with-others' + | 'files-shares-via-link' + +export const createLocationShares = (name: shareTypes, location = {}): RouteLocationNamedRaw => + createLocation(name, location) + +export const locationShares = createLocationShares('files-shares') +export const locationSharesWithMe = createLocationShares('files-shares-with-me') +export const locationSharesWithOthers = createLocationShares('files-shares-with-others') +export const locationSharesViaLink = createLocationShares('files-shares-via-link') + +export const isLocationSharesActive = isLocationActiveDirector( + locationSharesWithMe, + locationSharesWithOthers, + locationSharesViaLink +) + +export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ + { + name: locationShares.name, + path: '/shares', + component: components.App, + redirect: locationSharesWithMe, + children: [ + { + name: locationSharesWithMe.name, + path: 'with-me', + component: components.Shares.SharedWithMe, + meta: { + authContext: 'user', + title: $gettext('Files shared with me') + } + }, + { + name: locationSharesWithOthers.name, + path: 'with-others', + component: components.Shares.SharedWithOthers, + meta: { + authContext: 'user', + title: $gettext('Files shared with others') + } + }, + { + name: locationSharesViaLink.name, + path: 'via-link', + component: components.Shares.SharedViaLink, + meta: { + authContext: 'user', + title: $gettext('Files shared via link') + } + } + ] + } +] diff --git a/packages/web-pkg/src/router/spaces.ts b/packages/web-pkg/src/router/spaces.ts new file mode 100644 index 00000000000..d9e62f40b12 --- /dev/null +++ b/packages/web-pkg/src/router/spaces.ts @@ -0,0 +1,46 @@ +import { RouteLocationNamedRaw, RouteRecordRaw } from 'vue-router' +import { RouteComponents } from './router' +import { createLocation, isLocationActiveDirector, $gettext } from './utils' + +type spaceTypes = 'files-spaces-projects' | 'files-spaces-generic' + +export const createLocationSpaces = (name: spaceTypes, location = {}): RouteLocationNamedRaw => + createLocation(name, location) + +export const locationSpacesProjects = createLocationSpaces('files-spaces-projects') +export const locationSpacesGeneric = createLocationSpaces('files-spaces-generic') + +// FIXME: `isLocationSpacesActive('files-spaces-generic') returns true for 'files-spaces-projects' as well +// TODO: if that's fixed, adjust the `loaderSpaceGeneric#isActive` and `loaderShare#isActive` +export const isLocationSpacesActive = isLocationActiveDirector( + locationSpacesProjects, + locationSpacesGeneric +) + +export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ + { + path: '/spaces', + component: components.App, + children: [ + { + path: 'projects', + name: locationSpacesProjects.name, + component: components.Spaces.Projects, + meta: { + authContext: 'user', + title: $gettext('Spaces') + } + }, + { + path: ':driveAliasAndItem(.*)?', + name: locationSpacesGeneric.name, + component: components.Spaces.DriveResolver, + meta: { + authContext: 'user', + patchCleanPath: true, + contextQueryItems: ['sort-by', 'sort-dir'] + } + } + ] + } +] diff --git a/packages/web-pkg/src/router/trash.ts b/packages/web-pkg/src/router/trash.ts new file mode 100644 index 00000000000..fa20ad400df --- /dev/null +++ b/packages/web-pkg/src/router/trash.ts @@ -0,0 +1,44 @@ +import { RouteComponents } from './router' +import { RouteLocationNamedRaw, RouteRecordRaw } from 'vue-router' +import { $gettext, createLocation, isLocationActiveDirector } from './utils' + +type trashTypes = 'files-trash-generic' | 'files-trash-overview' + +export const createLocationTrash = (name: trashTypes, location = {}): RouteLocationNamedRaw => + createLocation(name, location) + +export const locationTrashGeneric = createLocationTrash('files-trash-generic') + +export const locationTrashOverview = createLocationTrash('files-trash-overview') + +export const isLocationTrashActive = isLocationActiveDirector( + locationTrashGeneric, + locationTrashOverview +) + +export const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ + { + path: '/trash', + component: components.App, + children: [ + { + path: 'overview', + name: locationTrashOverview.name, + component: components.Trash.Overview, + meta: { + authContext: 'user', + title: $gettext('Trash overview') + } + }, + { + name: locationTrashGeneric.name, + path: ':driveAliasAndItem(.*)?', + component: components.Spaces.DriveResolver, + meta: { + authContext: 'user', + patchCleanPath: true + } + } + ] + } +] diff --git a/packages/web-pkg/src/router/utils.ts b/packages/web-pkg/src/router/utils.ts new file mode 100644 index 00000000000..10c407a5e55 --- /dev/null +++ b/packages/web-pkg/src/router/utils.ts @@ -0,0 +1,101 @@ +import { Router, RouteLocationNamedRaw } from 'vue-router' +import merge from 'lodash-es/merge' +import { unref } from 'vue' + +export interface ActiveRouteDirectorFunc { + (router: Router, ...comparatives: T[]): boolean +} + +/** + * helper function to find out if comparative route location is active or not. + * it uses vue router resolve to do so. + * + * @param router + * @param comparatives + */ +export const isLocationActive = ( + router: Router, + ...comparatives: [RouteLocationNamedRaw, ...RouteLocationNamedRaw[]] +): boolean => { + // FIXME: router.resolve cleans the path. we don't need it, if we can rely on + // router.currentRoute to not have slashs encoded for paths + const { href: currentHref } = router.resolve(unref(router.currentRoute)) + return comparatives + .map((comparative) => { + const { href: comparativeHref } = router.resolve({ + ...comparative + // ...(comparative.name && { name: resolveRouteName(comparative.name) }) + }) + + /** + * Href might be '/' or '#/' if router is not able to resolve the proper path. + * This happens if the we don't pass a param which is defined in the route configuration, for example: + * path: user/:id + * + * This implies that the comparative route is not active + **/ + if (comparativeHref === '/' || comparativeHref === '#/') { + return false + } + return currentHref.startsWith(comparativeHref) + }) + .some(Boolean) +} + +/** + * wraps isLocationActive to be used as a closure, + * the resulting closure then can be used to check a location against the defined set of director locations + * + * @param defaultComparatives + */ +export const isLocationActiveDirector = ( + ...defaultComparatives: [RouteLocationNamedRaw, ...RouteLocationNamedRaw[]] +): ActiveRouteDirectorFunc => { + return (router: Router, ...comparatives: T[]): boolean => { + if (!comparatives.length) { + return isLocationActive(router, ...defaultComparatives) + } + + const [first, ...rest] = comparatives.map((name) => { + const match = defaultComparatives.find((c) => c.name === name) + + if (!match) { + throw new Error(`unknown comparative '${name}'`) + } + + return match + }) + + return isLocationActive(router, first, ...rest) + } +} + +/** + * just a dummy function to trick gettext tools + * + * @param msg + */ +export function $gettext(msg: string): string { + return msg +} + +/** + * create a location with attached default values + * + * @param name + * @param locations + */ +export const createLocation = ( + name: string, + ...locations: RouteLocationNamedRaw[] +): RouteLocationNamedRaw => + merge( + {}, + { + name + }, + ...locations.map((location) => ({ + ...(location.params && { params: location.params }), + ...(location.query && { query: location.query }) + })) + ) diff --git a/packages/web-pkg/src/services/folder.ts b/packages/web-pkg/src/services/folder.ts new file mode 100644 index 00000000000..8d5da35dad7 --- /dev/null +++ b/packages/web-pkg/src/services/folder.ts @@ -0,0 +1,72 @@ +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { useRouter, useClientService, useStore } from 'web-pkg/src/composables' +import { unref } from 'vue' +import { Store } from 'vuex' +import { ClientService } from 'web-pkg/src/services/client' + +import { + FolderLoaderSpace, + FolderLoaderFavorites, + FolderLoaderSharedViaLink, + FolderLoaderSharedWithMe, + FolderLoaderSharedWithOthers, + FolderLoaderTrashbin +} from './folder/index' + +export * from './folder/types' + +export type FolderLoaderTask = any + +export type TaskContext = { + clientService: ClientService + store: Store + router: Router +} + +export interface FolderLoader { + isEnabled(store: Store): boolean + isActive(router: Router): boolean + getTask(options: TaskContext): FolderLoaderTask +} + +export class FolderService { + private readonly loaders: FolderLoader[] + + constructor() { + this.loaders = [ + new FolderLoaderSpace(), + new FolderLoaderFavorites(), + new FolderLoaderSharedViaLink(), + new FolderLoaderSharedWithMe(), + new FolderLoaderSharedWithOthers(), + new FolderLoaderTrashbin() + ] + } + + public getTask(): FolderLoaderTask { + const store = useStore() + const router = useRouter() + const clientService = useClientService() + const loader = this.loaders.find((l) => l.isEnabled(unref(store)) && l.isActive(unref(router))) + if (!loader) { + console.error('No folder loader found for route') + return + } + + return useTask(function* (...args) { + const context = { + clientService, + store, + router + } + try { + yield loader.getTask(context).perform(...args) + } catch (e) { + console.error(e) + } + }) + } +} + +export const folderService = new FolderService() diff --git a/packages/web-pkg/src/services/folder/index.ts b/packages/web-pkg/src/services/folder/index.ts new file mode 100644 index 00000000000..4141cf304ad --- /dev/null +++ b/packages/web-pkg/src/services/folder/index.ts @@ -0,0 +1,7 @@ +export * from './loaderSpace' +export * from './loaderFavorites' +export * from './loaderSharedViaLink' +export * from './loaderSharedWithMe' +export * from './loaderSharedWithOthers' +export * from './loaderTrashbin' +export * from './types' diff --git a/packages/web-pkg/src/services/folder/loaderFavorites.ts b/packages/web-pkg/src/services/folder/loaderFavorites.ts new file mode 100644 index 00000000000..7c3ca8531a4 --- /dev/null +++ b/packages/web-pkg/src/services/folder/loaderFavorites.ts @@ -0,0 +1,47 @@ +import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { DavProperties } from 'web-client/src/webdav/constants' +import { buildResource } from 'web-client/src/helpers' +import { isLocationCommonActive } from '../../router' + +export class FolderLoaderFavorites implements FolderLoader { + public isEnabled(): boolean { + return true + } + + public isActive(router: Router): boolean { + return isLocationCommonActive(router, 'files-common-favorites') + } + + public getTask(context: TaskContext): FolderLoaderTask { + const { + store, + clientService: { owncloudSdk: client } + } = context + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return useTask(function* (signal1, signal2) { + store.commit('Files/CLEAR_CURRENT_FILES_LIST') + store.commit('Files/SET_ANCESTOR_META_DATA', {}) + + let resources = yield client.files.getFavoriteFiles(DavProperties.Default) + resources = resources.map((f) => { + const resource = buildResource(f) + // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. + if (!resource.storageId) { + resource.storageId = store.getters.user.id + } + return resource + }) + store.commit('Files/LOAD_FILES', { + currentFolder: null, + files: resources + }) + store.dispatch('Files/loadIndicators', { + client: client, + currentFolder: '/' + }) + }) + } +} diff --git a/packages/web-pkg/src/services/folder/loaderSharedViaLink.ts b/packages/web-pkg/src/services/folder/loaderSharedViaLink.ts new file mode 100644 index 00000000000..c0f3e6f9a85 --- /dev/null +++ b/packages/web-pkg/src/services/folder/loaderSharedViaLink.ts @@ -0,0 +1,67 @@ +import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { isLocationSharesActive } from '../../router' +import { ShareTypes } from 'web-client/src/helpers/share' +import { aggregateResourceShares } from '../../helpers/resources' +import { Store } from 'vuex' +import { + useCapabilityFilesSharingResharing, + useCapabilityShareJailEnabled +} from 'web-pkg/src/composables' +import { unref } from 'vue' + +export class FolderLoaderSharedViaLink implements FolderLoader { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isEnabled(store: Store): boolean { + return true + } + + public isActive(router: Router): boolean { + return isLocationSharesActive(router, 'files-shares-via-link') + } + + public getTask(context: TaskContext): FolderLoaderTask { + const { + store, + clientService: { owncloudSdk: client } + } = context + + const hasResharing = useCapabilityFilesSharingResharing(store) + const hasShareJail = useCapabilityShareJailEnabled(store) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return useTask(function* (signal1, signal2) { + store.commit('Files/CLEAR_CURRENT_FILES_LIST') + store.commit('Files/SET_ANCESTOR_META_DATA', {}) + + let resources = yield client.shares.getShares('', { + share_types: ShareTypes.link.value.toString(), + include_tags: false + }) + + resources = resources.map((r) => r.shareInfo) + const spaces = store.getters['runtime/spaces/spaces'] + if (resources.length) { + resources = aggregateResourceShares( + resources, + false, + unref(hasResharing), + unref(hasShareJail), + spaces + ).map((resource) => { + // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. + if (!resource.storageId) { + resource.storageId = store.getters.user.id + } + return resource + }) + } + + store.commit('Files/LOAD_FILES', { + currentFolder: null, + files: resources + }) + }) + } +} diff --git a/packages/web-pkg/src/services/folder/loaderSharedWithMe.ts b/packages/web-pkg/src/services/folder/loaderSharedWithMe.ts new file mode 100644 index 00000000000..d56e2a2ae56 --- /dev/null +++ b/packages/web-pkg/src/services/folder/loaderSharedWithMe.ts @@ -0,0 +1,63 @@ +import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { aggregateResourceShares } from '../../helpers/resources' +import { isLocationSharesActive } from '../../router' +import { Store } from 'vuex' +import { + useCapabilityFilesSharingResharing, + useCapabilityShareJailEnabled +} from 'web-pkg/src/composables' +import { unref } from 'vue' + +export class FolderLoaderSharedWithMe implements FolderLoader { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isEnabled(store: Store): boolean { + return true + } + + public isActive(router: Router): boolean { + return isLocationSharesActive(router, 'files-shares-with-me') + } + + public getTask(context: TaskContext): FolderLoaderTask { + const { store, clientService } = context + + const hasResharing = useCapabilityFilesSharingResharing(store) + const hasShareJail = useCapabilityShareJailEnabled(store) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return useTask(function* (signal1, signal2) { + store.commit('Files/CLEAR_CURRENT_FILES_LIST') + store.commit('Files/SET_ANCESTOR_META_DATA', {}) + + let resources = yield clientService.owncloudSdk.shares.getShares('', { + state: 'all', + include_tags: false, + shared_with_me: true + }) + + resources = resources.map((r) => r.shareInfo) + + if (resources.length) { + resources = aggregateResourceShares( + resources, + true, + unref(hasResharing), + unref(hasShareJail) + ).map((resource) => { + // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. + if (!resource.storageId) { + resource.storageId = store.getters.user.id + } + return resource + }) + } + + store.commit('Files/LOAD_FILES', { + currentFolder: null, + files: resources + }) + }) + } +} diff --git a/packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts b/packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts new file mode 100644 index 00000000000..78d9730bf5d --- /dev/null +++ b/packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts @@ -0,0 +1,71 @@ +import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { isLocationSharesActive } from '../../router' +import { aggregateResourceShares } from '../../helpers/resources' +import { Store } from 'vuex' +import { peopleRoleDenyFolder, ShareTypes } from 'web-client/src/helpers/share' +import { + useCapabilityFilesSharingResharing, + useCapabilityShareJailEnabled +} from 'web-pkg/src/composables' +import { unref } from 'vue' + +export class FolderLoaderSharedWithOthers implements FolderLoader { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isEnabled(store: Store): boolean { + return true + } + + public isActive(router: Router): boolean { + return isLocationSharesActive(router, 'files-shares-with-others') + } + + public getTask(context: TaskContext): FolderLoaderTask { + const { + store, + clientService: { owncloudSdk: client } + } = context + + const hasResharing = useCapabilityFilesSharingResharing(store) + const hasShareJail = useCapabilityShareJailEnabled(store) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return useTask(function* (signal1, signal2) { + store.commit('Files/CLEAR_CURRENT_FILES_LIST') + store.commit('Files/SET_ANCESTOR_META_DATA', {}) + + const shareTypes = ShareTypes.authenticated + .filter( + (type) => ![ShareTypes.spaceUser.value, ShareTypes.spaceGroup.value].includes(type.value) + ) + .map((share) => share.value) + .join(',') + + let resources = yield client.shares.getShares('', { + share_types: shareTypes, + reshares: true, + include_tags: false + }) + resources = resources + .filter((r) => parseInt(r.shareInfo.permissions) !== peopleRoleDenyFolder.bitmask(false)) + .map((r) => r.shareInfo) + if (resources.length) { + resources = aggregateResourceShares( + resources, + false, + unref(hasResharing), + unref(hasShareJail) + ).map((resource) => { + // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. + if (!resource.storageId) { + resource.storageId = store.getters.user.id + } + return resource + }) + } + + store.commit('Files/LOAD_FILES', { currentFolder: null, files: resources }) + }) + } +} diff --git a/packages/web-pkg/src/services/folder/loaderSpace.ts b/packages/web-pkg/src/services/folder/loaderSpace.ts new file mode 100644 index 00000000000..b9b8cb6ce8e --- /dev/null +++ b/packages/web-pkg/src/services/folder/loaderSpace.ts @@ -0,0 +1,94 @@ +import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { isLocationPublicActive, isLocationSpacesActive } from '../../router' +import { useCapabilityFilesSharingResharing } from 'web-pkg/src/composables' +import { SpaceResource } from 'web-client/src/helpers' +import { unref } from 'vue' +import { FolderLoaderOptions } from './types' +import { useFileRouteReplace } from 'web-pkg/src/composables/router/useFileRouteReplace' +import { aggregateResourceShares } from '../../helpers/resources' +import { getIndicators } from 'web-pkg/src/helpers/statusIndicators' + +export class FolderLoaderSpace implements FolderLoader { + public isEnabled(): boolean { + return true + } + + public isActive(router: Router): boolean { + // TODO: remove next check when isLocationSpacesActive doesn't return true for generic route when being on projects overview. + if (isLocationSpacesActive(router, 'files-spaces-projects')) { + return false + } + return ( + isLocationSpacesActive(router, 'files-spaces-generic') || + isLocationPublicActive(router, 'files-public-link') + ) + } + + public getTask(context: TaskContext): FolderLoaderTask { + const { store, router, clientService } = context + const { owncloudSdk: client, webdav } = clientService + const { replaceInvalidFileRoute } = useFileRouteReplace({ router }) + const hasResharing = useCapabilityFilesSharingResharing(store) + + return useTask(function* ( + signal1, + signal2, + space: SpaceResource, + path: string = null, + fileId: string | number = null, + options: FolderLoaderOptions = {} + ) { + try { + store.commit('Files/CLEAR_CURRENT_FILES_LIST') + + let { resource: currentFolder, children: resources } = yield webdav.listFiles(space, { + path, + fileId + }) + // if current folder has no id (= singe file public link) we must not correct the route + if (currentFolder.id) { + replaceInvalidFileRoute({ space, resource: currentFolder, path, fileId }) + } + + if (path === '/') { + if (space.driveType === 'share') { + const parentShare = yield client.shares.getShare(space.shareId) + const aggregatedShares = aggregateResourceShares( + [parentShare.shareInfo], + true, + unref(hasResharing), + true + ) + currentFolder = aggregatedShares[0] + } else if (!['personal', 'public'].includes(space.driveType)) { + // note: in the future we might want to show the space as root for personal spaces as well (to show quota and the like). Currently not needed. + currentFolder = space + } + } + + yield store.dispatch('Files/loadAncestorMetaData', { + folder: currentFolder, + space, + client: webdav + }) + + if (options.loadShares) { + const ancestorMetaData = store.getters['Files/ancestorMetaData'] + for (const file of resources) { + file.indicators = getIndicators({ resource: file, ancestorMetaData }) + } + } + + store.commit('Files/LOAD_FILES', { + currentFolder, + files: resources + }) + } catch (error) { + store.commit('Files/SET_CURRENT_FOLDER', null) + console.error(error) + } + }).restartable() + } +} diff --git a/packages/web-pkg/src/services/folder/loaderTrashbin.ts b/packages/web-pkg/src/services/folder/loaderTrashbin.ts new file mode 100644 index 00000000000..3531e752d40 --- /dev/null +++ b/packages/web-pkg/src/services/folder/loaderTrashbin.ts @@ -0,0 +1,45 @@ +import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' +import { Router } from 'vue-router' +import { useTask } from 'vue-concurrency' +import { DavProperties } from 'web-client/src/webdav/constants' +import { isLocationTrashActive } from '../../router' +import { buildDeletedResource, buildWebDavFilesTrashPath } from '../../helpers/resources' +import { Store } from 'vuex' +import { Resource } from 'web-client' +import { useCapabilityShareJailEnabled } from 'web-pkg/src/composables' +import { unref } from 'vue' +import { buildResource, buildWebDavSpacesTrashPath } from 'web-client/src/helpers' + +export class FolderLoaderTrashbin implements FolderLoader { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public isEnabled(store: Store): boolean { + return true + } + + public isActive(router: Router): boolean { + return isLocationTrashActive(router, 'files-trash-generic') + } + + public getTask(context: TaskContext): FolderLoaderTask { + const { + store, + clientService: { owncloudSdk: client } + } = context + const hasShareJail = useCapabilityShareJailEnabled(store) + + return useTask(function* (signal1, signal2, space: Resource) { + store.commit('Files/CLEAR_CURRENT_FILES_LIST') + store.commit('Files/SET_ANCESTOR_META_DATA', {}) + + const path = unref(hasShareJail) + ? buildWebDavSpacesTrashPath(space.id) + : buildWebDavFilesTrashPath(space.id) + const resources = yield client.fileTrash.list(path, '1', DavProperties.Trashbin) + + store.commit('Files/LOAD_FILES', { + currentFolder: buildResource(resources[0]), + files: resources.slice(1).map(buildDeletedResource) + }) + }) + } +} diff --git a/packages/web-pkg/src/services/folder/types.ts b/packages/web-pkg/src/services/folder/types.ts new file mode 100644 index 00000000000..9e63bc6fb87 --- /dev/null +++ b/packages/web-pkg/src/services/folder/types.ts @@ -0,0 +1,3 @@ +export interface FolderLoaderOptions { + loadShares?: boolean +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04e8b0729a5..35d90ca7f52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,6 +655,33 @@ importers: specifier: workspace:@ownclouders/web-pkg@* version: link:../web-pkg + packages/web-app-backups: + dependencies: + axios: + specifier: ^0.27.2 + version: 0.27.2 + fuse.js: + specifier: ^6.5.3 + version: 6.5.3 + lodash-es: + specifier: 4.17.21 + version: 4.17.21 + uuid: + specifier: ^9.0.0 + version: 9.0.0 + vue-concurrency: + specifier: 4.0.0 + version: 4.0.0(vue@3.3.4) + vuex: + specifier: 4.1.0 + version: 4.1.0(vue@3.3.4) + web-client: + specifier: npm:@ownclouders/web-client + version: link:../web-client + web-pkg: + specifier: npm:@ownclouders/web-pkg + version: link:../web-pkg + packages/web-app-draw-io: dependencies: luxon: @@ -934,6 +961,9 @@ importers: mark.js: specifier: ^8.11.1 version: 8.11.1 + p-queue: + specifier: ^6.6.2 + version: 6.6.2 pinia: specifier: ^2.1.3 version: 2.1.3(typescript@5.0.3)(vue@3.3.4) @@ -20499,6 +20529,15 @@ packages: engines: {node: '>=0.10.0'} requiresBuild: true + /vue-concurrency@4.0.0(vue@3.3.4): + resolution: {integrity: sha512-YOLrHang8w15bMOpftw6iPsoqlZT/u5zsblnT+CrVvc2HZ0eP76hjj77sgepHxB6FRcN3dlFXDNBMQ6iZzpiNw==} + peerDependencies: + vue: ^3.2.20 || ^2.7.0 + dependencies: + caf: 15.0.1 + vue: 3.3.4 + dev: false + /vue-concurrency@4.0.1(vue@3.3.4): resolution: {integrity: sha512-gzZjC8o9EE1/d7OEndI0C8lO09nkLSbviT94/gQR372iLVbYKZiGto87upjV9TAHodTfzbBgjyQXH53AVI5ugA==} peerDependencies: From 93968b79eb6f742904d289551ab45c5cd625b508 Mon Sep 17 00:00:00 2001 From: Jacob Noah Date: Fri, 15 Sep 2023 13:32:55 +0200 Subject: [PATCH 2/7] remove FilesViewWrapper.vue, repurposed useScrollTo, replaced SharesNavigation.vue with slot --- .../useKeyboardTableNavigation.ts | 8 +- .../src/composables/scrollTo/index.ts | 1 - .../src/composables/scrollTo/useScrollTo.ts | 33 ----- .../web-pkg/src/components/AppBar/AppBar.vue | 3 +- .../components/AppBar/SharesNavigation.vue | 140 ------------------ .../src/components/FilesViewWrapper.vue | 41 ----- .../files/useFileActionsCreateNewFolder.ts | 2 +- .../src/composables/scrollTo/useScrollTo.ts | 19 ++- 8 files changed, 19 insertions(+), 228 deletions(-) delete mode 100644 packages/web-app-admin-settings/src/composables/scrollTo/index.ts delete mode 100644 packages/web-app-admin-settings/src/composables/scrollTo/useScrollTo.ts delete mode 100644 packages/web-pkg/src/components/AppBar/SharesNavigation.vue delete mode 100644 packages/web-pkg/src/components/FilesViewWrapper.vue diff --git a/packages/web-app-admin-settings/src/composables/keyboardActions/useKeyboardTableNavigation.ts b/packages/web-app-admin-settings/src/composables/keyboardActions/useKeyboardTableNavigation.ts index 161394c99dc..e1886f57b10 100644 --- a/packages/web-app-admin-settings/src/composables/keyboardActions/useKeyboardTableNavigation.ts +++ b/packages/web-app-admin-settings/src/composables/keyboardActions/useKeyboardTableNavigation.ts @@ -1,4 +1,4 @@ -import { useScrollTo } from 'web-app-admin-settings/src/composables/scrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' import { Ref, unref } from 'vue' import { Key, KeyboardActions, ModifierKey } from 'web-pkg/src/composables/keyboardActions' import { find, findIndex } from 'lodash-es' @@ -59,7 +59,7 @@ export const useKeyboardTableNavigation = ( lastSelectedRowIndex.value = nextResourceIndex lastSelectedRowId.value = String(nextResource.id) - scrollToResource(nextResource.id) + scrollToResource(nextResource.id, { topbarElement: 'admin-settings-app-bar' }) } const handleShiftUpAction = async () => { @@ -86,7 +86,7 @@ export const useKeyboardTableNavigation = ( lastSelectedRowIndex.value = nextResourceIndex lastSelectedRowId.value = String(nextResource.id) keyActions.selectionCursor.value = unref(keyActions.selectionCursor) - 1 - scrollToResource(nextResource.id) + scrollToResource(nextResource.id, { topbarElement: 'admin-settings-app-bar' }) } const handleShiftDownAction = () => { const nextResource = getNextResource(false) @@ -121,7 +121,7 @@ export const useKeyboardTableNavigation = ( lastSelectedRowIndex.value = nextResourceIndex lastSelectedRowId.value = String(nextResource.id) keyActions.selectionCursor.value = unref(keyActions.selectionCursor) + 1 - scrollToResource(nextResource.id) + scrollToResource(nextResource.id, { topbarElement: 'admin-settings-app-bar' }) } const handleSelectAllAction = () => { diff --git a/packages/web-app-admin-settings/src/composables/scrollTo/index.ts b/packages/web-app-admin-settings/src/composables/scrollTo/index.ts deleted file mode 100644 index e59d4c066df..00000000000 --- a/packages/web-app-admin-settings/src/composables/scrollTo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useScrollTo' diff --git a/packages/web-app-admin-settings/src/composables/scrollTo/useScrollTo.ts b/packages/web-app-admin-settings/src/composables/scrollTo/useScrollTo.ts deleted file mode 100644 index f6b1b890ad9..00000000000 --- a/packages/web-app-admin-settings/src/composables/scrollTo/useScrollTo.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Resource } from 'web-client/src' - -export interface ScrollToResult { - scrollToResource(resourceId: Resource['id'], options?: { forceScroll?: boolean }): void -} - -export const useScrollTo = (): ScrollToResult => { - const scrollToResource = (resourceId: string, options = { forceScroll: false }) => { - const resourceElement = document.querySelectorAll( - `[data-item-id='${resourceId}']` - )[0] as HTMLElement - - if (!resourceElement) { - return - } - - const topbarElement = document.getElementById('admin-settings-app-bar') - // topbar height + th height + height of one row = offset needed when scrolling top - const topOffset = topbarElement.offsetHeight + resourceElement.offsetHeight * 2 - - if ( - resourceElement.getBoundingClientRect().bottom > window.innerHeight || - resourceElement.getBoundingClientRect().top < topOffset || - options.forceScroll - ) { - resourceElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - } - - return { - scrollToResource - } -} diff --git a/packages/web-pkg/src/components/AppBar/AppBar.vue b/packages/web-pkg/src/components/AppBar/AppBar.vue index 9f82a465fb5..daeb7487eec 100644 --- a/packages/web-pkg/src/components/AppBar/AppBar.vue +++ b/packages/web-pkg/src/components/AppBar/AppBar.vue @@ -29,7 +29,7 @@ - +
- - - - - diff --git a/packages/web-pkg/src/components/FilesViewWrapper.vue b/packages/web-pkg/src/components/FilesViewWrapper.vue deleted file mode 100644 index ffb6b7ce7c7..00000000000 --- a/packages/web-pkg/src/components/FilesViewWrapper.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts index 3aab4c4fc31..ec88dff8159 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFolder.ts @@ -76,7 +76,7 @@ export const useFileActionsCreateNewFolder = ({ }) await nextTick() - scrollToResource(resource.id, { forceScroll: true }) + scrollToResource(resource.id, { forceScroll: true, topbarElement: 'files-app-bar' }) } catch (error) { console.error(error) store.dispatch('showErrorMessage', { diff --git a/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts b/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts index 1a7aaa5044a..28b82bb36b3 100644 --- a/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts +++ b/packages/web-pkg/src/composables/scrollTo/useScrollTo.ts @@ -7,8 +7,11 @@ import { useRouteQuery } from 'web-pkg/src/composables' import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' export interface ScrollToResult { - scrollToResource(resourceId: Resource['id'], options?: { forceScroll?: boolean }): void - scrollToResourceFromRoute(resources: Resource[]): void + scrollToResource( + resourceId: Resource['id'], + options?: { forceScroll?: boolean; topbarElement: string } + ): void + scrollToResourceFromRoute(resources: Resource[], topbarElement: string): void } export const useScrollTo = (): ScrollToResult => { @@ -22,7 +25,10 @@ export const useScrollTo = (): ScrollToResult => { return queryItemAsString(unref(detailsQuery)) }) - const scrollToResource = (resourceId: Resource['id'], options = { forceScroll: false }) => { + const scrollToResource = ( + resourceId: Resource['id'], + options = { forceScroll: false, topbarElement: null } + ) => { const resourceElement = document.querySelectorAll( `[data-item-id='${resourceId}']` )[0] as HTMLElement @@ -31,7 +37,8 @@ export const useScrollTo = (): ScrollToResult => { return } - const topbarElement = document.getElementById('files-app-bar') + // files-app-bar, admin-settings-app-bar + const topbarElement = document.getElementById(options.topbarElement) // topbar height + th height + height of one row = offset needed when scrolling top const topOffset = topbarElement.offsetHeight + resourceElement.offsetHeight * 2 @@ -44,7 +51,7 @@ export const useScrollTo = (): ScrollToResult => { } } - const scrollToResourceFromRoute = (resources: Resource[]) => { + const scrollToResourceFromRoute = (resources: Resource[], topbarElement: null) => { if (!unref(scrollTo) || !resources.length) { return } @@ -52,7 +59,7 @@ export const useScrollTo = (): ScrollToResult => { const resource = unref(resources).find((r) => r.id === unref(scrollTo)) if (resource && resource.processing !== true) { store.commit('Files/SET_FILE_SELECTION', [resource]) - scrollToResource(resource.id, { forceScroll: true }) + scrollToResource(resource.id, { forceScroll: true, topbarElement }) if (unref(details)) { eventBus.publish(SideBarEventTopics.openWithPanel, unref(details)) From cb49a35b2fc052460ffae0854a9b82310e34933e Mon Sep 17 00:00:00 2001 From: Jacob Noah Date: Fri, 15 Sep 2023 13:41:41 +0200 Subject: [PATCH 3/7] removed import of FileViewWrapper --- .../web-app-backups/src/views/Backups.vue | 4 -- pnpm-lock.yaml | 39 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/web-app-backups/src/views/Backups.vue b/packages/web-app-backups/src/views/Backups.vue index 1f568c4c9b8..20975a1e09b 100644 --- a/packages/web-app-backups/src/views/Backups.vue +++ b/packages/web-app-backups/src/views/Backups.vue @@ -1,8 +1,6 @@ @@ -10,12 +8,10 @@ import { mapGetters } from 'vuex' import BackupsSection from '../components/BackupsSection.vue' import { defineComponent } from 'vue' -import FilesViewWrapper from 'web-pkg/src/components/FilesViewWrapper.vue' import AppBar from 'web-pkg/src/components/AppBar/AppBar.vue' export default defineComponent({ components: { - FilesViewWrapper, AppBar, BackupsSection }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 847d90b8d55..36fc88a3d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -658,6 +658,33 @@ importers: specifier: workspace:@ownclouders/web-pkg@* version: link:../web-pkg + packages/web-app-backups: + dependencies: + axios: + specifier: ^0.27.2 + version: 0.27.2 + fuse.js: + specifier: ^6.5.3 + version: 6.5.3 + lodash-es: + specifier: 4.17.21 + version: 4.17.21 + uuid: + specifier: ^9.0.0 + version: 9.0.0 + vue-concurrency: + specifier: 4.0.0 + version: 4.0.0(vue@3.3.4) + vuex: + specifier: 4.1.0 + version: 4.1.0(vue@3.3.4) + web-client: + specifier: npm:@ownclouders/web-client + version: link:../web-client + web-pkg: + specifier: npm:@ownclouders/web-pkg + version: link:../web-pkg + packages/web-app-draw-io: dependencies: luxon: @@ -943,6 +970,9 @@ importers: mark.js: specifier: ^8.11.1 version: 8.11.1 + p-queue: + specifier: ^6.6.2 + version: 6.6.2 password-sheriff: specifier: ^1.1.1 version: 1.1.1 @@ -20516,6 +20546,15 @@ packages: engines: {node: '>=0.10.0'} requiresBuild: true + /vue-concurrency@4.0.0(vue@3.3.4): + resolution: {integrity: sha512-YOLrHang8w15bMOpftw6iPsoqlZT/u5zsblnT+CrVvc2HZ0eP76hjj77sgepHxB6FRcN3dlFXDNBMQ6iZzpiNw==} + peerDependencies: + vue: ^3.2.20 || ^2.7.0 + dependencies: + caf: 15.0.1 + vue: 3.3.4 + dev: false + /vue-concurrency@4.0.1(vue@3.3.4): resolution: {integrity: sha512-gzZjC8o9EE1/d7OEndI0C8lO09nkLSbviT94/gQR372iLVbYKZiGto87upjV9TAHodTfzbBgjyQXH53AVI5ugA==} peerDependencies: From 8ff37f1323edcdf292855d1b8e6c62ffd9d81e49 Mon Sep 17 00:00:00 2001 From: Jacob Noah Date: Fri, 15 Sep 2023 17:13:34 +0200 Subject: [PATCH 4/7] replacing useScrollTo from web-app-files for web-pkg --- .../files/useFileActionsCreateNewFolder.ts | 4 +- .../useKeyboardTableNavigation.ts | 8 +- .../useResourcesViewDefaults.ts | 2 +- .../src/composables/scrollTo/index.ts | 1 - .../src/composables/scrollTo/useScrollTo.ts | 76 ------------------- .../src/views/spaces/Projects.vue | 4 +- .../tests/mocks/useScrollToMock.ts | 2 +- .../useFileActionsCreateNewFolder.spec.ts | 4 +- .../composables/scrollTo/useScrollTo.spec.ts | 12 +-- 9 files changed, 18 insertions(+), 95 deletions(-) delete mode 100644 packages/web-app-files/src/composables/scrollTo/index.ts delete mode 100644 packages/web-app-files/src/composables/scrollTo/useScrollTo.ts diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts index 604aaade3d3..ccd99a04580 100644 --- a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts @@ -9,7 +9,7 @@ import { join } from 'path' import { WebDAV } from 'web-client/src/webdav' import { isLocationSpacesActive } from 'web-app-files/src/router' import { getIndicators } from 'web-app-files/src/helpers/statusIndicators' -import { useScrollTo } from '../../scrollTo/useScrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' import { AncestorMetaData } from 'web-pkg/src/types' export const useFileActionsCreateNewFolder = ({ @@ -79,7 +79,7 @@ export const useFileActionsCreateNewFolder = ({ }) await nextTick() - scrollToResource(resource.id, { forceScroll: true }) + scrollToResource(resource.id, { forceScroll: true, topbarElement: 'files-app-bar' }) } catch (error) { console.error(error) store.dispatch('showErrorMessage', { diff --git a/packages/web-app-files/src/composables/keyboardActions/useKeyboardTableNavigation.ts b/packages/web-app-files/src/composables/keyboardActions/useKeyboardTableNavigation.ts index 3d92f0d748f..871e8c7877f 100644 --- a/packages/web-app-files/src/composables/keyboardActions/useKeyboardTableNavigation.ts +++ b/packages/web-app-files/src/composables/keyboardActions/useKeyboardTableNavigation.ts @@ -1,5 +1,5 @@ import { QueryValue, useStore, ViewModeConstants } from 'web-pkg' -import { useScrollTo } from 'web-app-files/src/composables/scrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' import { computed, Ref, ref, unref, nextTick, watchEffect } from 'vue' import { Key, KeyboardActions, ModifierKey } from 'web-pkg/src/composables/keyboardActions' import { Resource } from 'web-client' @@ -121,7 +121,7 @@ export const useKeyboardTableNavigation = ( await nextTick() store.commit('Files/ADD_FILE_SELECTION', { id: nextId }) await nextTick() - scrollToResource(nextId) + scrollToResource(nextId, { topbarElement: 'files-app-bar' }) } const getNextResourceId = (previous = false, movedBy = 1) => { @@ -291,7 +291,7 @@ export const useKeyboardTableNavigation = ( } else { store.commit('Files/ADD_FILE_SELECTION', { id: nextResourceId }) } - scrollToResource(nextResourceId) + scrollToResource(nextResourceId, { topbarElement: 'files-app-bar' }) keyActions.selectionCursor.value = unref(keyActions.selectionCursor) - 1 } const handleShiftDownAction = async (movedBy = 1) => { @@ -305,7 +305,7 @@ export const useKeyboardTableNavigation = ( } else { store.commit('Files/ADD_FILE_SELECTION', { id: nextResourceId }) } - scrollToResource(nextResourceId) + scrollToResource(nextResourceId, { topbarElement: 'files-app-bar' }) keyActions.selectionCursor.value = unref(keyActions.selectionCursor) + 1 } diff --git a/packages/web-app-files/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts b/packages/web-app-files/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts index 2a683e5312d..c234097a6e0 100644 --- a/packages/web-app-files/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts +++ b/packages/web-app-files/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts @@ -23,7 +23,7 @@ import { ViewModeConstants } from 'web-pkg/src/composables' -import { ScrollToResult, useScrollTo } from '../scrollTo' +import { ScrollToResult, useScrollTo } from 'web-pkg/src/composables/scrollTo' interface ResourcesViewDefaultsOptions { loadResourcesTask?: Task diff --git a/packages/web-app-files/src/composables/scrollTo/index.ts b/packages/web-app-files/src/composables/scrollTo/index.ts deleted file mode 100644 index e59d4c066df..00000000000 --- a/packages/web-app-files/src/composables/scrollTo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useScrollTo' diff --git a/packages/web-app-files/src/composables/scrollTo/useScrollTo.ts b/packages/web-app-files/src/composables/scrollTo/useScrollTo.ts deleted file mode 100644 index 6c61b298ad1..00000000000 --- a/packages/web-app-files/src/composables/scrollTo/useScrollTo.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { computed, unref } from 'vue' -import { Resource } from 'web-client/src' -import { queryItemAsString } from 'web-pkg/src/composables/appDefaults/useAppNavigation' -import { useStore } from 'web-pkg/src/composables/store/useStore' -import { eventBus } from 'web-pkg/src/services' -import { useRouteQuery } from 'web-pkg/src/composables' -import { SideBarEventTopics } from 'web-pkg/src/composables/sideBar' - -export interface ScrollToResult { - scrollToResource(resourceId: Resource['id'], options?: { forceScroll?: boolean }): void - scrollToResourceFromRoute(resources: Resource[]): void -} - -export const useScrollTo = (): ScrollToResult => { - const store = useStore() - const scrollToQuery = useRouteQuery('scrollTo') - const detailsQuery = useRouteQuery('details') - const scrollTo = computed(() => { - return queryItemAsString(unref(scrollToQuery)) - }) - const details = computed(() => { - return queryItemAsString(unref(detailsQuery)) - }) - - const scrollToResource = (resourceId: Resource['id'], options = { forceScroll: false }) => { - const resourceElement = document.querySelectorAll( - `[data-item-id='${resourceId}']` - )[0] as HTMLElement - - if (!resourceElement) { - eventBus.publish('app.files.navigate.page', { resourceId, forceScroll: options.forceScroll }) - return - } - - const topbarElement = document.getElementById('files-app-bar') - // topbar height + th height + height of one row = offset needed when scrolling top - const topOffset = topbarElement.offsetHeight + resourceElement.offsetHeight * 2 - - if ( - resourceElement.getBoundingClientRect().bottom > window.innerHeight || - resourceElement.getBoundingClientRect().top < topOffset || - options.forceScroll - ) { - resourceElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - } - eventBus.subscribe( - 'app.files.navigate.scrollTo', - (data: { resourceId: string; forceScroll: boolean }) => - scrollToResource(data.resourceId, { forceScroll: data.forceScroll }) - ) - - const scrollToResourceFromRoute = (resources: Resource[]) => { - if (!unref(scrollTo) || !resources.length) { - return - } - - const resource = unref(resources).find((r) => r.id === unref(scrollTo)) - - if (!resource || resource.processing === true) { - return - } - - store.commit('Files/SET_FILE_SELECTION', [resource]) - scrollToResource(resource.id, { forceScroll: true }) - - if (unref(details)) { - eventBus.publish(SideBarEventTopics.openWithPanel, unref(details)) - } - } - - return { - scrollToResource, - scrollToResourceFromRoute - } -} diff --git a/packages/web-app-files/src/views/spaces/Projects.vue b/packages/web-app-files/src/views/spaces/Projects.vue index 7dad1078c7a..51eb78845f2 100644 --- a/packages/web-app-files/src/views/spaces/Projects.vue +++ b/packages/web-app-files/src/views/spaces/Projects.vue @@ -183,7 +183,7 @@ import ResourceTable from '../../components/FilesList/ResourceTable.vue' import { eventBus } from 'web-pkg/src/services/eventBus' import { SideBarEventTopics, useSideBar } from 'web-pkg/src/composables/sideBar' import { WebDAV } from 'web-client/src/webdav' -import { useScrollTo } from 'web-app-files/src/composables/scrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' import { useSelectedResources } from 'web-app-files/src/composables' import { sortFields as availableSortFields } from '../../helpers/ui/resourceTiles' import { defaultFuseOptions, formatFileSize } from 'web-pkg/src' @@ -355,7 +355,7 @@ export default defineComponent({ onMounted(async () => { await loadResourcesTask.perform() - scrollToResourceFromRoute(unref(spaces)) + scrollToResourceFromRoute(unref(spaces), 'files-app-bar') nextTick(() => { markInstance.value = new Mark('.spaces-table') }) diff --git a/packages/web-app-files/tests/mocks/useScrollToMock.ts b/packages/web-app-files/tests/mocks/useScrollToMock.ts index 30ea04e052b..28d0cc3a1c5 100644 --- a/packages/web-app-files/tests/mocks/useScrollToMock.ts +++ b/packages/web-app-files/tests/mocks/useScrollToMock.ts @@ -1,4 +1,4 @@ -import { useScrollTo } from 'web-app-files/src/composables/scrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' export const useScrollToMock = ( options: Partial> = {} diff --git a/packages/web-app-files/tests/unit/composables/actions/files/useFileActionsCreateNewFolder.spec.ts b/packages/web-app-files/tests/unit/composables/actions/files/useFileActionsCreateNewFolder.spec.ts index 5077d02915e..1d59c23f9aa 100644 --- a/packages/web-app-files/tests/unit/composables/actions/files/useFileActionsCreateNewFolder.spec.ts +++ b/packages/web-app-files/tests/unit/composables/actions/files/useFileActionsCreateNewFolder.spec.ts @@ -11,9 +11,9 @@ import { getComposableWrapper } from 'web-test-helpers/src' import { useScrollToMock } from 'web-app-files/tests/mocks/useScrollToMock' -import { useScrollTo } from 'web-app-files/src/composables/scrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' -jest.mock('web-app-files/src/composables/scrollTo') +jest.mock('web-pkg/src/composables/scrollTo') describe('useFileActionsCreateNewFolder', () => { describe('checkFolderName', () => { diff --git a/packages/web-app-files/tests/unit/composables/scrollTo/useScrollTo.spec.ts b/packages/web-app-files/tests/unit/composables/scrollTo/useScrollTo.spec.ts index 138adf831e3..aed26615e4e 100644 --- a/packages/web-app-files/tests/unit/composables/scrollTo/useScrollTo.spec.ts +++ b/packages/web-app-files/tests/unit/composables/scrollTo/useScrollTo.spec.ts @@ -1,5 +1,5 @@ import { mock, mockDeep } from 'jest-mock-extended' -import { useScrollTo } from 'web-app-files/src/composables/scrollTo' +import { useScrollTo } from 'web-pkg/src/composables/scrollTo' import { Resource } from 'web-client/src' import { eventBus } from 'web-pkg/src' import { defaultComponentMocks } from 'web-test-helpers/src/mocks/defaultComponentMocks' @@ -87,7 +87,7 @@ describe('useScrollTo', () => { const resource = mockDeep({ id: resourceId }) const { scrollToResourceFromRoute } = useScrollTo() const querySelectorAllSpy = jest.spyOn(document, 'querySelectorAll') - scrollToResourceFromRoute([resource]) + scrollToResourceFromRoute([resource], 'files-app-bar') expect(querySelectorAllSpy).not.toHaveBeenCalled() }, { @@ -109,7 +109,7 @@ describe('useScrollTo', () => { const resource = mockDeep({ id: 'someOtherFileId' }) const { scrollToResourceFromRoute } = useScrollTo() const querySelectorAllSpy = jest.spyOn(document, 'querySelectorAll') - scrollToResourceFromRoute([resource]) + scrollToResourceFromRoute([resource], 'files-app-bar') expect(querySelectorAllSpy).not.toHaveBeenCalled() }, { @@ -131,7 +131,7 @@ describe('useScrollTo', () => { const resource = mockDeep({ id: resourceId, processing: true }) const { scrollToResourceFromRoute } = useScrollTo() const querySelectorAllSpy = jest.spyOn(document, 'querySelectorAll') - scrollToResourceFromRoute([resource]) + scrollToResourceFromRoute([resource], 'files-app-bar') expect(querySelectorAllSpy).not.toHaveBeenCalled() }, { @@ -155,7 +155,7 @@ describe('useScrollTo', () => { const resource = mockDeep({ id: resourceId }) const { scrollToResourceFromRoute } = useScrollTo() const querySelectorAllSpy = jest.spyOn(document, 'querySelectorAll') - scrollToResourceFromRoute([resource]) + scrollToResourceFromRoute([resource], 'files-app-bar') expect(querySelectorAllSpy).toHaveBeenCalled() }, { @@ -183,7 +183,7 @@ describe('useScrollTo', () => { const busStub = jest.spyOn(eventBus, 'publish') const resource = mockDeep({ id: resourceId }) const { scrollToResourceFromRoute } = useScrollTo() - scrollToResourceFromRoute([resource]) + scrollToResourceFromRoute([resource], 'files-app-bar') expect(busStub).toHaveBeenCalled() }, { From 68f2a346fee3286d4a6845aceaf64cdf97eac651 Mon Sep 17 00:00:00 2001 From: Jacob Noah Date: Mon, 18 Sep 2023 18:36:30 +0200 Subject: [PATCH 5/7] removed useResourceViewDefault dependency with useSelectedResources, backup app --- .../src/components/BackupsSection.vue | 5 +- packages/web-pkg/src/composables/index.ts | 1 - .../resourcesViewDefaults/index.ts | 1 - .../useResourcesViewDefaults.ts | 119 ------------------ packages/web-pkg/src/services/folder.ts | 72 ----------- packages/web-pkg/src/services/folder/index.ts | 7 -- .../src/services/folder/loaderFavorites.ts | 47 ------- .../services/folder/loaderSharedViaLink.ts | 67 ---------- .../src/services/folder/loaderSharedWithMe.ts | 63 ---------- .../services/folder/loaderSharedWithOthers.ts | 71 ----------- .../src/services/folder/loaderSpace.ts | 94 -------------- .../src/services/folder/loaderTrashbin.ts | 45 ------- packages/web-pkg/src/services/folder/types.ts | 3 - 13 files changed, 2 insertions(+), 593 deletions(-) delete mode 100644 packages/web-pkg/src/composables/resourcesViewDefaults/index.ts delete mode 100644 packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts delete mode 100644 packages/web-pkg/src/services/folder.ts delete mode 100644 packages/web-pkg/src/services/folder/index.ts delete mode 100644 packages/web-pkg/src/services/folder/loaderFavorites.ts delete mode 100644 packages/web-pkg/src/services/folder/loaderSharedViaLink.ts delete mode 100644 packages/web-pkg/src/services/folder/loaderSharedWithMe.ts delete mode 100644 packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts delete mode 100644 packages/web-pkg/src/services/folder/loaderSpace.ts delete mode 100644 packages/web-pkg/src/services/folder/loaderTrashbin.ts delete mode 100644 packages/web-pkg/src/services/folder/types.ts diff --git a/packages/web-app-backups/src/components/BackupsSection.vue b/packages/web-app-backups/src/components/BackupsSection.vue index cb601191a13..200d9f914e6 100644 --- a/packages/web-app-backups/src/components/BackupsSection.vue +++ b/packages/web-app-backups/src/components/BackupsSection.vue @@ -102,12 +102,10 @@ import { mapActions, mapMutations, mapGetters } from 'vuex' import { useStore } from 'web-pkg/src/composables' import NoContentMessage from 'web-pkg/src/components/NoContentMessage.vue' -import { useResourcesViewDefaults } from 'web-pkg/src/composables' import AppLoadingSpinner from 'web-pkg/src/components/AppLoadingSpinner.vue' import { useTask } from 'vue-concurrency' import { extractDomSelector } from 'web-client/src/helpers/resource' import { basename } from 'path' -import { Resource } from 'web-client' import { buildWebDavFilesPath } from 'web-pkg/src/helpers/resources' import { buildResource } from 'web-client/src/helpers' import { DavProperties } from 'web-client/src/webdav/constants' @@ -116,6 +114,7 @@ import { breadcrumbsFromPath, concatBreadcrumbs } from 'web-pkg/src/helpers/brea import { createLocationSpaces } from 'web-pkg/src/router' import { CreateTargetRouteOptions } from 'web-pkg/src/helpers/folderLink' import { useClientService } from 'web-pkg/src/composables' +import { useSelectedResources } from 'web-pkg/src/composables/selection' const visibilityObserver = new VisibilityObserver() @@ -138,7 +137,7 @@ export default defineComponent({ const accessToken = store.getters['runtime/auth/accessToken'] const { owncloudSdk } = useClientService() - const { selectedResourcesIds } = useResourcesViewDefaults() + const { selectedResourcesIds } = useSelectedResources({ store }) const fetchResources = async (client, path, properties, signal = null) => { const options = signal ? { signal } : {} diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts index 4f946bb3b23..0173787fbb7 100644 --- a/packages/web-pkg/src/composables/index.ts +++ b/packages/web-pkg/src/composables/index.ts @@ -19,4 +19,3 @@ export * from './viewMode' export * from './search' export * from './sse' export * from './passwordPolicyService' -export * from './resourcesViewDefaults' diff --git a/packages/web-pkg/src/composables/resourcesViewDefaults/index.ts b/packages/web-pkg/src/composables/resourcesViewDefaults/index.ts deleted file mode 100644 index 45cac086e8f..00000000000 --- a/packages/web-pkg/src/composables/resourcesViewDefaults/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useResourcesViewDefaults' diff --git a/packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts b/packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts deleted file mode 100644 index 2a683e5312d..00000000000 --- a/packages/web-pkg/src/composables/resourcesViewDefaults/useResourcesViewDefaults.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { nextTick, computed, unref, Ref } from 'vue' -import { folderService } from '../../services/folder' -import { fileList } from '../../helpers/ui' -import { usePagination, useSort, SortDir, SortField, useRouteName } from 'web-pkg/src/composables' -import { useSideBar } from 'web-pkg/src/composables/sideBar' - -import { - queryItemAsString, - useMutationSubscription, - useRouteQuery, - useStore -} from 'web-pkg/src/composables' -import { determineSortFields as determineResourceTableSortFields } from '../../helpers/ui/resourceTable' -import { determineSortFields as determineResourceTilesSortFields } from '../../helpers/ui/resourceTiles' -import { Task } from 'vue-concurrency' -import { Resource } from 'web-client' -import { useSelectedResources, SelectedResourcesResult } from '../selection' -import { ReadOnlyRef } from 'web-pkg' -import { - useFileListHeaderPosition, - useViewMode, - useViewSize, - ViewModeConstants -} from 'web-pkg/src/composables' - -import { ScrollToResult, useScrollTo } from '../scrollTo' - -interface ResourcesViewDefaultsOptions { - loadResourcesTask?: Task -} - -type ResourcesViewDefaultsResult = { - fileListHeaderY: Ref - refreshFileListHeaderPosition(): void - loadResourcesTask: Task - areResourcesLoading: ReadOnlyRef - storeItems: ReadOnlyRef - sortFields: ReadOnlyRef - paginatedResources: Ref - paginationPages: ReadOnlyRef - paginationPage: ReadOnlyRef - handleSort({ sortBy, sortDir }: { sortBy: string; sortDir: SortDir }): void - sortBy: ReadOnlyRef - sortDir: ReadOnlyRef - viewMode: ReadOnlyRef - viewSize: ReadOnlyRef - selectedResources: Ref - selectedResourcesIds: Ref<(string | number)[]> - isResourceInSelection(resource: Resource): boolean - - sideBarOpen: Ref - sideBarActivePanel: Ref -} & SelectedResourcesResult & - ScrollToResult - -export const useResourcesViewDefaults = ( - options: ResourcesViewDefaultsOptions = {} -): ResourcesViewDefaultsResult => { - const loadResourcesTask = options.loadResourcesTask || folderService.getTask() - const areResourcesLoading = computed(() => { - return loadResourcesTask.isRunning || !loadResourcesTask.last - }) - - const store = useStore() - const { refresh: refreshFileListHeaderPosition, y: fileListHeaderY } = useFileListHeaderPosition() - - const storeItems = computed((): T[] => store.getters['Files/activeFiles'] || []) - - const currentRoute = useRouteName() - const currentViewModeQuery = useRouteQuery( - `${unref(currentRoute)}-view-mode`, - ViewModeConstants.defaultModeName - ) - const currentViewMode = computed((): string => queryItemAsString(currentViewModeQuery.value)) - const viewMode = useViewMode(currentViewMode) - - const currentTilesSizeQuery = useRouteQuery('tiles-size', '1') - const currentTilesSize = computed((): string => String(currentTilesSizeQuery.value)) - const viewSize = useViewSize(currentTilesSize) - - const sortFields = computed((): SortField[] => { - if (unref(viewMode) === ViewModeConstants.tilesView.name) { - return determineResourceTilesSortFields(unref(storeItems)[0]) - } - return determineResourceTableSortFields(unref(storeItems)[0]) - }) - - const { sortBy, sortDir, items, handleSort } = useSort({ items: storeItems, fields: sortFields }) - const { - items: paginatedResources, - total: paginationPages, - page: paginationPage - } = usePagination({ items, perPageStoragePrefix: 'files' }) - - useMutationSubscription(['Files/UPSERT_RESOURCE'], async ({ payload }) => { - await nextTick() - fileList.accentuateItem(payload.id) - }) - - return { - fileListHeaderY, - refreshFileListHeaderPosition, - loadResourcesTask, - areResourcesLoading, - storeItems, - sortFields, - viewMode, - viewSize, - paginatedResources, - paginationPages, - paginationPage, - handleSort, - sortBy, - sortDir, - ...useSelectedResources({ store }), - ...useSideBar(), - ...useScrollTo() - } -} diff --git a/packages/web-pkg/src/services/folder.ts b/packages/web-pkg/src/services/folder.ts deleted file mode 100644 index 8d5da35dad7..00000000000 --- a/packages/web-pkg/src/services/folder.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { useRouter, useClientService, useStore } from 'web-pkg/src/composables' -import { unref } from 'vue' -import { Store } from 'vuex' -import { ClientService } from 'web-pkg/src/services/client' - -import { - FolderLoaderSpace, - FolderLoaderFavorites, - FolderLoaderSharedViaLink, - FolderLoaderSharedWithMe, - FolderLoaderSharedWithOthers, - FolderLoaderTrashbin -} from './folder/index' - -export * from './folder/types' - -export type FolderLoaderTask = any - -export type TaskContext = { - clientService: ClientService - store: Store - router: Router -} - -export interface FolderLoader { - isEnabled(store: Store): boolean - isActive(router: Router): boolean - getTask(options: TaskContext): FolderLoaderTask -} - -export class FolderService { - private readonly loaders: FolderLoader[] - - constructor() { - this.loaders = [ - new FolderLoaderSpace(), - new FolderLoaderFavorites(), - new FolderLoaderSharedViaLink(), - new FolderLoaderSharedWithMe(), - new FolderLoaderSharedWithOthers(), - new FolderLoaderTrashbin() - ] - } - - public getTask(): FolderLoaderTask { - const store = useStore() - const router = useRouter() - const clientService = useClientService() - const loader = this.loaders.find((l) => l.isEnabled(unref(store)) && l.isActive(unref(router))) - if (!loader) { - console.error('No folder loader found for route') - return - } - - return useTask(function* (...args) { - const context = { - clientService, - store, - router - } - try { - yield loader.getTask(context).perform(...args) - } catch (e) { - console.error(e) - } - }) - } -} - -export const folderService = new FolderService() diff --git a/packages/web-pkg/src/services/folder/index.ts b/packages/web-pkg/src/services/folder/index.ts deleted file mode 100644 index 4141cf304ad..00000000000 --- a/packages/web-pkg/src/services/folder/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './loaderSpace' -export * from './loaderFavorites' -export * from './loaderSharedViaLink' -export * from './loaderSharedWithMe' -export * from './loaderSharedWithOthers' -export * from './loaderTrashbin' -export * from './types' diff --git a/packages/web-pkg/src/services/folder/loaderFavorites.ts b/packages/web-pkg/src/services/folder/loaderFavorites.ts deleted file mode 100644 index 7c3ca8531a4..00000000000 --- a/packages/web-pkg/src/services/folder/loaderFavorites.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { DavProperties } from 'web-client/src/webdav/constants' -import { buildResource } from 'web-client/src/helpers' -import { isLocationCommonActive } from '../../router' - -export class FolderLoaderFavorites implements FolderLoader { - public isEnabled(): boolean { - return true - } - - public isActive(router: Router): boolean { - return isLocationCommonActive(router, 'files-common-favorites') - } - - public getTask(context: TaskContext): FolderLoaderTask { - const { - store, - clientService: { owncloudSdk: client } - } = context - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return useTask(function* (signal1, signal2) { - store.commit('Files/CLEAR_CURRENT_FILES_LIST') - store.commit('Files/SET_ANCESTOR_META_DATA', {}) - - let resources = yield client.files.getFavoriteFiles(DavProperties.Default) - resources = resources.map((f) => { - const resource = buildResource(f) - // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. - if (!resource.storageId) { - resource.storageId = store.getters.user.id - } - return resource - }) - store.commit('Files/LOAD_FILES', { - currentFolder: null, - files: resources - }) - store.dispatch('Files/loadIndicators', { - client: client, - currentFolder: '/' - }) - }) - } -} diff --git a/packages/web-pkg/src/services/folder/loaderSharedViaLink.ts b/packages/web-pkg/src/services/folder/loaderSharedViaLink.ts deleted file mode 100644 index c0f3e6f9a85..00000000000 --- a/packages/web-pkg/src/services/folder/loaderSharedViaLink.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { isLocationSharesActive } from '../../router' -import { ShareTypes } from 'web-client/src/helpers/share' -import { aggregateResourceShares } from '../../helpers/resources' -import { Store } from 'vuex' -import { - useCapabilityFilesSharingResharing, - useCapabilityShareJailEnabled -} from 'web-pkg/src/composables' -import { unref } from 'vue' - -export class FolderLoaderSharedViaLink implements FolderLoader { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isEnabled(store: Store): boolean { - return true - } - - public isActive(router: Router): boolean { - return isLocationSharesActive(router, 'files-shares-via-link') - } - - public getTask(context: TaskContext): FolderLoaderTask { - const { - store, - clientService: { owncloudSdk: client } - } = context - - const hasResharing = useCapabilityFilesSharingResharing(store) - const hasShareJail = useCapabilityShareJailEnabled(store) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return useTask(function* (signal1, signal2) { - store.commit('Files/CLEAR_CURRENT_FILES_LIST') - store.commit('Files/SET_ANCESTOR_META_DATA', {}) - - let resources = yield client.shares.getShares('', { - share_types: ShareTypes.link.value.toString(), - include_tags: false - }) - - resources = resources.map((r) => r.shareInfo) - const spaces = store.getters['runtime/spaces/spaces'] - if (resources.length) { - resources = aggregateResourceShares( - resources, - false, - unref(hasResharing), - unref(hasShareJail), - spaces - ).map((resource) => { - // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. - if (!resource.storageId) { - resource.storageId = store.getters.user.id - } - return resource - }) - } - - store.commit('Files/LOAD_FILES', { - currentFolder: null, - files: resources - }) - }) - } -} diff --git a/packages/web-pkg/src/services/folder/loaderSharedWithMe.ts b/packages/web-pkg/src/services/folder/loaderSharedWithMe.ts deleted file mode 100644 index d56e2a2ae56..00000000000 --- a/packages/web-pkg/src/services/folder/loaderSharedWithMe.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { aggregateResourceShares } from '../../helpers/resources' -import { isLocationSharesActive } from '../../router' -import { Store } from 'vuex' -import { - useCapabilityFilesSharingResharing, - useCapabilityShareJailEnabled -} from 'web-pkg/src/composables' -import { unref } from 'vue' - -export class FolderLoaderSharedWithMe implements FolderLoader { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isEnabled(store: Store): boolean { - return true - } - - public isActive(router: Router): boolean { - return isLocationSharesActive(router, 'files-shares-with-me') - } - - public getTask(context: TaskContext): FolderLoaderTask { - const { store, clientService } = context - - const hasResharing = useCapabilityFilesSharingResharing(store) - const hasShareJail = useCapabilityShareJailEnabled(store) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return useTask(function* (signal1, signal2) { - store.commit('Files/CLEAR_CURRENT_FILES_LIST') - store.commit('Files/SET_ANCESTOR_META_DATA', {}) - - let resources = yield clientService.owncloudSdk.shares.getShares('', { - state: 'all', - include_tags: false, - shared_with_me: true - }) - - resources = resources.map((r) => r.shareInfo) - - if (resources.length) { - resources = aggregateResourceShares( - resources, - true, - unref(hasResharing), - unref(hasShareJail) - ).map((resource) => { - // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. - if (!resource.storageId) { - resource.storageId = store.getters.user.id - } - return resource - }) - } - - store.commit('Files/LOAD_FILES', { - currentFolder: null, - files: resources - }) - }) - } -} diff --git a/packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts b/packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts deleted file mode 100644 index 78d9730bf5d..00000000000 --- a/packages/web-pkg/src/services/folder/loaderSharedWithOthers.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { isLocationSharesActive } from '../../router' -import { aggregateResourceShares } from '../../helpers/resources' -import { Store } from 'vuex' -import { peopleRoleDenyFolder, ShareTypes } from 'web-client/src/helpers/share' -import { - useCapabilityFilesSharingResharing, - useCapabilityShareJailEnabled -} from 'web-pkg/src/composables' -import { unref } from 'vue' - -export class FolderLoaderSharedWithOthers implements FolderLoader { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isEnabled(store: Store): boolean { - return true - } - - public isActive(router: Router): boolean { - return isLocationSharesActive(router, 'files-shares-with-others') - } - - public getTask(context: TaskContext): FolderLoaderTask { - const { - store, - clientService: { owncloudSdk: client } - } = context - - const hasResharing = useCapabilityFilesSharingResharing(store) - const hasShareJail = useCapabilityShareJailEnabled(store) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return useTask(function* (signal1, signal2) { - store.commit('Files/CLEAR_CURRENT_FILES_LIST') - store.commit('Files/SET_ANCESTOR_META_DATA', {}) - - const shareTypes = ShareTypes.authenticated - .filter( - (type) => ![ShareTypes.spaceUser.value, ShareTypes.spaceGroup.value].includes(type.value) - ) - .map((share) => share.value) - .join(',') - - let resources = yield client.shares.getShares('', { - share_types: shareTypes, - reshares: true, - include_tags: false - }) - resources = resources - .filter((r) => parseInt(r.shareInfo.permissions) !== peopleRoleDenyFolder.bitmask(false)) - .map((r) => r.shareInfo) - if (resources.length) { - resources = aggregateResourceShares( - resources, - false, - unref(hasResharing), - unref(hasShareJail) - ).map((resource) => { - // info: in oc10 we have no storageId in resources. All resources are mounted into the personal space. - if (!resource.storageId) { - resource.storageId = store.getters.user.id - } - return resource - }) - } - - store.commit('Files/LOAD_FILES', { currentFolder: null, files: resources }) - }) - } -} diff --git a/packages/web-pkg/src/services/folder/loaderSpace.ts b/packages/web-pkg/src/services/folder/loaderSpace.ts deleted file mode 100644 index b9b8cb6ce8e..00000000000 --- a/packages/web-pkg/src/services/folder/loaderSpace.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { isLocationPublicActive, isLocationSpacesActive } from '../../router' -import { useCapabilityFilesSharingResharing } from 'web-pkg/src/composables' -import { SpaceResource } from 'web-client/src/helpers' -import { unref } from 'vue' -import { FolderLoaderOptions } from './types' -import { useFileRouteReplace } from 'web-pkg/src/composables/router/useFileRouteReplace' -import { aggregateResourceShares } from '../../helpers/resources' -import { getIndicators } from 'web-pkg/src/helpers/statusIndicators' - -export class FolderLoaderSpace implements FolderLoader { - public isEnabled(): boolean { - return true - } - - public isActive(router: Router): boolean { - // TODO: remove next check when isLocationSpacesActive doesn't return true for generic route when being on projects overview. - if (isLocationSpacesActive(router, 'files-spaces-projects')) { - return false - } - return ( - isLocationSpacesActive(router, 'files-spaces-generic') || - isLocationPublicActive(router, 'files-public-link') - ) - } - - public getTask(context: TaskContext): FolderLoaderTask { - const { store, router, clientService } = context - const { owncloudSdk: client, webdav } = clientService - const { replaceInvalidFileRoute } = useFileRouteReplace({ router }) - const hasResharing = useCapabilityFilesSharingResharing(store) - - return useTask(function* ( - signal1, - signal2, - space: SpaceResource, - path: string = null, - fileId: string | number = null, - options: FolderLoaderOptions = {} - ) { - try { - store.commit('Files/CLEAR_CURRENT_FILES_LIST') - - let { resource: currentFolder, children: resources } = yield webdav.listFiles(space, { - path, - fileId - }) - // if current folder has no id (= singe file public link) we must not correct the route - if (currentFolder.id) { - replaceInvalidFileRoute({ space, resource: currentFolder, path, fileId }) - } - - if (path === '/') { - if (space.driveType === 'share') { - const parentShare = yield client.shares.getShare(space.shareId) - const aggregatedShares = aggregateResourceShares( - [parentShare.shareInfo], - true, - unref(hasResharing), - true - ) - currentFolder = aggregatedShares[0] - } else if (!['personal', 'public'].includes(space.driveType)) { - // note: in the future we might want to show the space as root for personal spaces as well (to show quota and the like). Currently not needed. - currentFolder = space - } - } - - yield store.dispatch('Files/loadAncestorMetaData', { - folder: currentFolder, - space, - client: webdav - }) - - if (options.loadShares) { - const ancestorMetaData = store.getters['Files/ancestorMetaData'] - for (const file of resources) { - file.indicators = getIndicators({ resource: file, ancestorMetaData }) - } - } - - store.commit('Files/LOAD_FILES', { - currentFolder, - files: resources - }) - } catch (error) { - store.commit('Files/SET_CURRENT_FOLDER', null) - console.error(error) - } - }).restartable() - } -} diff --git a/packages/web-pkg/src/services/folder/loaderTrashbin.ts b/packages/web-pkg/src/services/folder/loaderTrashbin.ts deleted file mode 100644 index 3531e752d40..00000000000 --- a/packages/web-pkg/src/services/folder/loaderTrashbin.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { FolderLoader, FolderLoaderTask, TaskContext } from '../folder' -import { Router } from 'vue-router' -import { useTask } from 'vue-concurrency' -import { DavProperties } from 'web-client/src/webdav/constants' -import { isLocationTrashActive } from '../../router' -import { buildDeletedResource, buildWebDavFilesTrashPath } from '../../helpers/resources' -import { Store } from 'vuex' -import { Resource } from 'web-client' -import { useCapabilityShareJailEnabled } from 'web-pkg/src/composables' -import { unref } from 'vue' -import { buildResource, buildWebDavSpacesTrashPath } from 'web-client/src/helpers' - -export class FolderLoaderTrashbin implements FolderLoader { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public isEnabled(store: Store): boolean { - return true - } - - public isActive(router: Router): boolean { - return isLocationTrashActive(router, 'files-trash-generic') - } - - public getTask(context: TaskContext): FolderLoaderTask { - const { - store, - clientService: { owncloudSdk: client } - } = context - const hasShareJail = useCapabilityShareJailEnabled(store) - - return useTask(function* (signal1, signal2, space: Resource) { - store.commit('Files/CLEAR_CURRENT_FILES_LIST') - store.commit('Files/SET_ANCESTOR_META_DATA', {}) - - const path = unref(hasShareJail) - ? buildWebDavSpacesTrashPath(space.id) - : buildWebDavFilesTrashPath(space.id) - const resources = yield client.fileTrash.list(path, '1', DavProperties.Trashbin) - - store.commit('Files/LOAD_FILES', { - currentFolder: buildResource(resources[0]), - files: resources.slice(1).map(buildDeletedResource) - }) - }) - } -} diff --git a/packages/web-pkg/src/services/folder/types.ts b/packages/web-pkg/src/services/folder/types.ts deleted file mode 100644 index 9e63bc6fb87..00000000000 --- a/packages/web-pkg/src/services/folder/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface FolderLoaderOptions { - loadShares?: boolean -} From b52095c9ed9256082aef0148a5b24b33a41de1df Mon Sep 17 00:00:00 2001 From: Jacob Noah Date: Wed, 20 Sep 2023 15:39:55 +0200 Subject: [PATCH 6/7] removing unnecessary files from web-pkg, migrating tests to web-pkg --- packages/web-app-backups/src/index.js | 1 - .../web-app-backups/src/views/Backups.vue | 3 +- packages/web-pkg/package.json | 3 + .../web-pkg/src/components/AppBar/AppBar.vue | 80 ++- .../components/FilesList/ResourceTable.vue | 26 +- .../actions/files/useFileActions.ts | 13 +- .../actions/files/useFileActionsCopy.ts | 17 +- .../files/useFileActionsCreateNewFile.ts | 5 +- .../files/useFileActionsCreateNewFolder.ts | 7 +- .../actions/files/useFileActionsDelete.ts | 20 +- .../files/useFileActionsDownloadArchive.ts | 24 +- .../actions/files/useFileActionsFavorite.ts | 11 +- .../actions/files/useFileActionsMove.ts | 4 + .../actions/files/useFileActionsPaste.ts | 3 +- .../actions/files/useFileActionsRename.ts | 30 +- .../actions/files/useFileActionsRestore.ts | 26 +- packages/web-pkg/src/composables/index.ts | 1 + .../src/helpers/resource/ancestorMetaData.ts | 9 - .../conflictHandling/conflictDialog.ts | 59 ++- .../web-pkg/src/helpers/resource/index.ts | 1 - .../src/helpers/share/sharedAncestorRoute.ts | 2 +- .../web-pkg/src/helpers/statusIndicators.ts | 2 +- packages/web-pkg/src/helpers/ui/filesList.ts | 12 - packages/web-pkg/src/helpers/ui/index.ts | 2 - .../web-pkg/src/helpers/ui/resourceTiles.ts | 76 --- .../web-pkg/tests/mocks/useScrollToMock.ts | 11 + .../unit/components/AppBar/AppBar.spec.ts | 183 +++++++ .../components/AppBar/SidebarToggle.spec.ts | 47 ++ .../AppBar/__snapshots__/AppBar.spec.ts.snap | 77 +++ .../__snapshots__/SidebarToggle.spec.ts.snap | 21 + .../FilesList/ContextActions.spec.ts | 84 ++++ .../FilesList/ResourceTable.spec.ts | 467 ++++++++++++++++++ .../files/useFileActionsAcceptShare.spec.ts | 67 +++ .../actions/files/useFileActionsCopy.spec.ts | 90 ++++ .../files/useFileActionsCreateNewFile.spec.ts | 146 ++++++ .../useFileActionsCreateNewFolder.spec.ts | 147 ++++++ .../files/useFileActionsDelete.spec.ts | 170 +++++++ .../useFileActionsDownloadArchive.spec.ts | 89 ++++ .../files/useFileActionsEmptyTrashBin.spec.ts | 142 ++++++ .../actions/files/useFileActionsMove.spec.ts | 85 ++++ .../files/useFileActionsRename.spec.ts | 205 ++++++++ .../files/useFileActionsRestore.spec.ts | 230 +++++++++ .../files/useFileActionsSetImage.spec.ts | 197 ++++++++ .../files/useFileActionsShowDetails.spec.ts | 32 ++ .../useFileActionsDeleteResources.spec.ts | 91 ++++ .../composables/scrollTo/useScrollTo.spec.ts | 199 ++++++++ .../tests/unit/helpers/breadcrumbs.spec.ts | 31 ++ .../tests/unit/helpers/permissions.spec.ts | 51 ++ .../helpers/resource/actions/upload.spec.ts | 125 +++++ .../conflictHandling/conflictDialog.spec.ts | 40 ++ .../helpers/resource/sameResource.spec.ts | 33 ++ .../tests/unit/helpers/share/link.spec.ts | 129 +++++ .../helpers/share/triggerShareAction.spec.ts | 77 +++ .../unit/helpers/statusIndicator.spec.ts | 37 ++ .../web-pkg/tests/unit/helpers/store.spec.ts | 25 + .../web-pkg/tests/unit/router/utils.spec.ts | 94 ++++ pnpm-lock.yaml | 22 +- 57 files changed, 3640 insertions(+), 241 deletions(-) delete mode 100644 packages/web-pkg/src/helpers/resource/ancestorMetaData.ts delete mode 100644 packages/web-pkg/src/helpers/ui/filesList.ts delete mode 100644 packages/web-pkg/src/helpers/ui/resourceTiles.ts create mode 100644 packages/web-pkg/tests/mocks/useScrollToMock.ts create mode 100644 packages/web-pkg/tests/unit/components/AppBar/AppBar.spec.ts create mode 100644 packages/web-pkg/tests/unit/components/AppBar/SidebarToggle.spec.ts create mode 100644 packages/web-pkg/tests/unit/components/AppBar/__snapshots__/AppBar.spec.ts.snap create mode 100644 packages/web-pkg/tests/unit/components/AppBar/__snapshots__/SidebarToggle.spec.ts.snap create mode 100644 packages/web-pkg/tests/unit/components/FilesList/ContextActions.spec.ts create mode 100644 packages/web-pkg/tests/unit/components/FilesList/ResourceTable.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsAcceptShare.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCopy.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateNewFile.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateNewFolder.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsDelete.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsDownloadArchive.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsEmptyTrashBin.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsMove.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsRename.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsRestore.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsSetImage.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/files/useFileActionsShowDetails.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/helpers/useFileActionsDeleteResources.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/scrollTo/useScrollTo.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/breadcrumbs.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/permissions.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/resource/actions/upload.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/resource/conflictHandling/conflictDialog.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/resource/sameResource.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/share/link.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/share/triggerShareAction.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/statusIndicator.spec.ts create mode 100644 packages/web-pkg/tests/unit/helpers/store.spec.ts create mode 100644 packages/web-pkg/tests/unit/router/utils.spec.ts diff --git a/packages/web-app-backups/src/index.js b/packages/web-app-backups/src/index.js index b9675baabc6..c892f477a68 100644 --- a/packages/web-app-backups/src/index.js +++ b/packages/web-app-backups/src/index.js @@ -1,7 +1,6 @@ import translations from '../l10n/translations' import Backups from './views/Backups.vue' - const appInfo = { name: 'Backups', id: 'backups', diff --git a/packages/web-app-backups/src/views/Backups.vue b/packages/web-app-backups/src/views/Backups.vue index 20975a1e09b..fd8f6cf47de 100644 --- a/packages/web-app-backups/src/views/Backups.vue +++ b/packages/web-app-backups/src/views/Backups.vue @@ -8,11 +8,10 @@ import { mapGetters } from 'vuex' import BackupsSection from '../components/BackupsSection.vue' import { defineComponent } from 'vue' -import AppBar from 'web-pkg/src/components/AppBar/AppBar.vue' export default defineComponent({ components: { - AppBar, + // AppBar, BackupsSection }, // setup() { diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 6a6189750f7..cf37d631751 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -13,6 +13,9 @@ "url": "https://github.com/owncloud/web", "directory": "packages/web-pkg" }, + "devDependencies": { + "@jest/globals": "29.3.1" + }, "peerDependencies": { "@casl/ability": "^6.3.3", "@casl/vue": "^2.2.1", diff --git a/packages/web-pkg/src/components/AppBar/AppBar.vue b/packages/web-pkg/src/components/AppBar/AppBar.vue index daeb7487eec..e42630bad4c 100644 --- a/packages/web-pkg/src/components/AppBar/AppBar.vue +++ b/packages/web-pkg/src/components/AppBar/AppBar.vue @@ -1,6 +1,13 @@ - - diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue index d85cd8107a0..dd112ac7ae3 100644 --- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue +++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue @@ -159,14 +159,14 @@ - - diff --git a/packages/web-app-files/src/components/FilesList/ContextActions.vue b/packages/web-app-files/src/components/FilesList/ContextActions.vue deleted file mode 100644 index 2cba8d3fc05..00000000000 --- a/packages/web-app-files/src/components/FilesList/ContextActions.vue +++ /dev/null @@ -1,188 +0,0 @@ - - - diff --git a/packages/web-app-files/src/components/FilesList/NotFoundMessage.vue b/packages/web-app-files/src/components/FilesList/NotFoundMessage.vue index db9bfa4246d..9b5a8d31418 100644 --- a/packages/web-app-files/src/components/FilesList/NotFoundMessage.vue +++ b/packages/web-app-files/src/components/FilesList/NotFoundMessage.vue @@ -50,7 +50,8 @@ import { createLocationSpaces, isLocationPublicActive, isLocationSpacesActive -} from '../../router' +} from 'web-pkg/src/router' + import { useRouter } from 'web-pkg/src/composables' import { defineComponent, PropType } from 'vue' import { SpaceResource } from 'web-client/src/helpers' diff --git a/packages/web-app-files/src/components/FilesList/ResourceDetails.vue b/packages/web-app-files/src/components/FilesList/ResourceDetails.vue index 7f63a34b145..5e4197e54bb 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceDetails.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceDetails.vue @@ -15,7 +15,7 @@ import { Resource, SpaceResource } from 'web-client/src/helpers' import FileActions from '../SideBar/Actions/FileActions.vue' import FileDetails from '../SideBar/Details/FileDetails.vue' import FileInfo from '../SideBar/FileInfo.vue' -import { useFileActions } from 'web-app-files/src/composables/actions/files/useFileActions' +import { useFileActions } from 'web-pkg/src/composables/actions/files/useFileActions' import { useRouteQuery } from 'web-pkg' export default defineComponent({ diff --git a/packages/web-app-files/src/components/FilesList/ResourceTable.vue b/packages/web-app-files/src/components/FilesList/ResourceTable.vue deleted file mode 100644 index 07d667d8423..00000000000 --- a/packages/web-app-files/src/components/FilesList/ResourceTable.vue +++ /dev/null @@ -1,1297 +0,0 @@ - - - - diff --git a/packages/web-app-files/src/components/FilesList/ResourceTiles.vue b/packages/web-app-files/src/components/FilesList/ResourceTiles.vue index b664b4c6a5d..d3d827f40d7 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceTiles.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceTiles.vue @@ -103,13 +103,13 @@ import { useStore, SortDir, SortField, ViewModeConstants } from 'web-pkg/src/com import { ImageDimension } from 'web-pkg/src/constants' import { createFileRouteOptions } from 'web-pkg/src/helpers/router' import { displayPositionedDropdown } from 'web-pkg/src/helpers/contextMenuDropdown' -import { createLocationSpaces } from 'web-app-files/src/router' +import { createLocationSpaces } from 'web-pkg/src/router' import ContextMenuQuickAction from 'web-pkg/src/components/ContextActions/ContextMenuQuickAction.vue' // Constants should match what is being used in OcTable/ResourceTable // Alignment regarding naming would be an API-breaking change and can // Be done at a later point in time? -import { useResourceRouteResolver } from '../../composables/filesList' +import { useResourceRouteResolver } from 'web-pkg/src/composables/filesList' import { eventBus } from 'web-pkg' export default defineComponent({ diff --git a/packages/web-app-files/src/components/Search/List.vue b/packages/web-app-files/src/components/Search/List.vue index 965ccb9c4db..2d6be7b9d04 100644 --- a/packages/web-app-files/src/components/Search/List.vue +++ b/packages/web-app-files/src/components/Search/List.vue @@ -112,16 +112,16 @@ import AppLoadingSpinner from 'web-pkg/src/components/AppLoadingSpinner.vue' import { VisibilityObserver } from 'web-pkg/src/observer' import { ImageType, ImageDimension } from 'web-pkg/src/constants' import NoContentMessage from 'web-pkg/src/components/NoContentMessage.vue' -import ResourceTable from '../FilesList/ResourceTable.vue' -import ContextActions from '../FilesList/ContextActions.vue' +import ResourceTable from 'web-pkg/src/components/FilesList/ResourceTable.vue' +import ContextActions from 'web-pkg/src/components/FilesList/ContextActions.vue' import { debounce } from 'lodash-es' import { mapMutations, mapGetters, mapActions } from 'vuex' import { useGettext } from 'vue3-gettext' -import AppBar from '../AppBar/AppBar.vue' +import AppBar from 'web-pkg/src/components/AppBar/AppBar.vue' import { computed, defineComponent, nextTick, onMounted, ref, unref, VNodeRef, watch } from 'vue' import ListInfo from '../FilesList/ListInfo.vue' import Pagination from 'web-pkg/src/components/Pagination.vue' -import { useFileActions } from '../../composables/actions/files/useFileActions' +import { useFileActions } from 'web-pkg/src/composables/actions/files/useFileActions' import { searchLimit } from '../../search/sdk/list' import { Resource } from 'web-client' import FilesViewWrapper from '../FilesViewWrapper.vue' @@ -143,7 +143,7 @@ import { onBeforeRouteLeave } from 'vue-router' import { useTask } from 'vue-concurrency' import { eventBus, useCapabilityFilesFullTextSearch } from 'web-pkg' import ItemFilter from 'web-pkg/src/components/ItemFilter.vue' -import { isLocationCommonActive } from 'web-app-files/src/router' +import { isLocationCommonActive } from 'web-pkg/src/router' import ItemFilterToggle from 'web-pkg/src/components/ItemFilterToggle.vue' import { useKeyboardActions } from 'web-pkg/src/composables/keyboardActions' import { diff --git a/packages/web-app-files/src/components/Search/Preview.vue b/packages/web-app-files/src/components/Search/Preview.vue index eb0100af1e8..8f76989abb9 100644 --- a/packages/web-app-files/src/components/Search/Preview.vue +++ b/packages/web-app-files/src/components/Search/Preview.vue @@ -21,14 +21,14 @@