Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>tags/v27.0.0beta1
@@ -51,7 +51,6 @@ | |||
* Initializes the files app | |||
*/ | |||
initialize: function() { | |||
this.navigation = OCP.Files.Navigation; | |||
this.$showHiddenFiles = $('input#showhiddenfilesToggle'); | |||
var showHidden = $('#showHiddenFiles').val() === "1"; | |||
this.$showHiddenFiles.prop('checked', showHidden); | |||
@@ -135,8 +134,6 @@ | |||
OC.Plugins.attach('OCA.Files.App', this); | |||
this._setupEvents(); | |||
// trigger URL change event handlers | |||
this._onPopState({ ...OC.Util.History.parseUrlQuery(), view: this.navigation?.active?.id }); | |||
this._debouncedPersistShowHiddenFilesState = _.debounce(this._persistShowHiddenFilesState, 1200); | |||
this._debouncedPersistCropImagePreviewsState = _.debounce(this._persistCropImagePreviewsState, 1200); | |||
@@ -145,6 +142,8 @@ | |||
OCP.WhatsNew.query(); // for Nextcloud server | |||
sessionStorage.setItem('WhatsNewServerCheck', Date.now()); | |||
} | |||
window._nc_event_bus.emit('files:legacy-view:initialized', this); | |||
}, | |||
/** |
@@ -0,0 +1,58 @@ | |||
<template> | |||
<NcBreadcrumbs data-cy-files-content-breadcrumbs> | |||
<!-- Current path sections --> | |||
<NcBreadcrumb v-for="section in sections" | |||
:key="section.dir" | |||
:aria-label="t('files', `Go to the '{dir}' directory`, section)" | |||
v-bind="section" /> | |||
</NcBreadcrumbs> | |||
</template> | |||
<script> | |||
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js' | |||
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js' | |||
import { basename } from 'path' | |||
export default { | |||
name: 'BreadCrumbs', | |||
components: { | |||
NcBreadcrumbs, | |||
NcBreadcrumb, | |||
}, | |||
props: { | |||
path: { | |||
type: String, | |||
default: '/', | |||
}, | |||
}, | |||
computed: { | |||
dirs() { | |||
const cumulativePath = (acc) => (value) => (acc += `${value}/`) | |||
return ['/', ...this.path.split('/').filter(Boolean).map(cumulativePath('/'))] | |||
}, | |||
sections() { | |||
return this.dirs.map(dir => { | |||
const to = { ...this.$route, query: { dir } } | |||
return { | |||
dir, | |||
to, | |||
title: basename(dir), | |||
} | |||
}) | |||
}, | |||
}, | |||
} | |||
</script> | |||
<style lang="scss" scoped> | |||
.breadcrumb { | |||
// Take as much space as possible | |||
flex: 1 1 100% !important; | |||
width: 100%; | |||
} | |||
</style> |
@@ -0,0 +1,134 @@ | |||
<!-- | |||
- @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> | |||
- | |||
- @author Gary Kim <gary@garykim.dev> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
- This program is free software: you can redistribute it and/or modify | |||
- it under the terms of the GNU Affero General Public License as | |||
- published by the Free Software Foundation, either version 3 of the | |||
- License, or (at your option) any later version. | |||
- | |||
- This program is distributed in the hope that it will be useful, | |||
- but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
- GNU Affero General Public License for more details. | |||
- | |||
- You should have received a copy of the GNU Affero General Public License | |||
- along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
- | |||
--> | |||
<template> | |||
<Fragment> | |||
<td class="files-list__row-checkbox"> | |||
<NcCheckboxRadioSwitch :aria-label="t('files', 'Select the row for {displayName}', { displayName })" | |||
:checked.sync="selectedFiles" | |||
:value="fileid.toString()" | |||
name="selectedFiles" /> | |||
</td> | |||
<!-- Icon or preview --> | |||
<td class="files-list__row-icon"> | |||
<FolderIcon v-if="source.type === 'folder'" /> | |||
</td> | |||
<!-- Link to file and --> | |||
<td class="files-list__row-name"> | |||
<a v-bind="linkTo"> | |||
{{ displayName }} | |||
</a> | |||
</td> | |||
</Fragment> | |||
</template> | |||
<script lang="ts"> | |||
import { Folder, File } from '@nextcloud/files' | |||
import { Fragment } from 'vue-fragment' | |||
import { join } from 'path' | |||
import { translate } from '@nextcloud/l10n' | |||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' | |||
import FolderIcon from 'vue-material-design-icons/Folder.vue' | |||
import logger from '../logger' | |||
export default { | |||
name: 'FileEntry', | |||
components: { | |||
FolderIcon, | |||
Fragment, | |||
NcCheckboxRadioSwitch, | |||
}, | |||
props: { | |||
index: { | |||
type: Number, | |||
required: true, | |||
}, | |||
source: { | |||
type: [File, Folder], | |||
required: true, | |||
}, | |||
}, | |||
computed: { | |||
dir() { | |||
// Remove any trailing slash but leave root slash | |||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') | |||
}, | |||
fileid() { | |||
return this.source.attributes.fileid | |||
}, | |||
displayName() { | |||
return this.source.attributes.displayName | |||
|| this.source.basename | |||
}, | |||
linkTo() { | |||
if (this.source.type === 'folder') { | |||
const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } } | |||
return { | |||
is: 'router-link', | |||
title: this.t('files', 'Open folder {name}', { name: this.displayName }), | |||
to, | |||
} | |||
} | |||
return { | |||
href: this.source.source, | |||
// TODO: Use first action title ? | |||
title: this.t('files', 'Download file {name}', { name: this.displayName }), | |||
} | |||
}, | |||
selectedFiles: { | |||
get() { | |||
return this.$store.state.selection.selected | |||
}, | |||
set(selection) { | |||
logger.debug('Added node to selection', { selection }) | |||
this.$store.dispatch('selection/set', selection) | |||
}, | |||
}, | |||
}, | |||
methods: { | |||
/** | |||
* Get a cached note from the store | |||
* | |||
* @param {number} fileId the file id to get | |||
* @return {Folder|File} | |||
*/ | |||
getNode(fileId) { | |||
return this.$store.getters['files/getNode'](fileId) | |||
}, | |||
t: translate, | |||
}, | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
@import '../mixins/fileslist-row.scss' | |||
</style> |
@@ -0,0 +1,122 @@ | |||
<!-- | |||
- @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> | |||
- | |||
- @author Gary Kim <gary@garykim.dev> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
- This program is free software: you can redistribute it and/or modify | |||
- it under the terms of the GNU Affero General Public License as | |||
- published by the Free Software Foundation, either version 3 of the | |||
- License, or (at your option) any later version. | |||
- | |||
- This program is distributed in the hope that it will be useful, | |||
- but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
- GNU Affero General Public License for more details. | |||
- | |||
- You should have received a copy of the GNU Affero General Public License | |||
- along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
- | |||
--> | |||
<template> | |||
<tr> | |||
<th class="files-list__row-checkbox"> | |||
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" /> | |||
</th> | |||
<!-- Icon or preview --> | |||
<th class="files-list__row-icon" /> | |||
<!-- Link to file and --> | |||
<th class="files-list__row-name"> | |||
{{ t('files', 'Name') }} | |||
</th> | |||
</tr> | |||
</template> | |||
<script lang="ts"> | |||
import { translate } from '@nextcloud/l10n' | |||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' | |||
import logger from '../logger' | |||
import { File, Folder } from '@nextcloud/files' | |||
export default { | |||
name: 'FilesListHeader', | |||
components: { | |||
NcCheckboxRadioSwitch, | |||
}, | |||
props: { | |||
nodes: { | |||
type: [File, Folder], | |||
required: true, | |||
}, | |||
}, | |||
computed: { | |||
dir() { | |||
// Remove any trailing slash but leave root slash | |||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') | |||
}, | |||
selectAllBind() { | |||
return { | |||
ariaLabel: this.isNoneSelected || this.isSomeSelected | |||
? this.t('files', 'Select all') | |||
: this.t('files', 'Unselect all'), | |||
checked: this.isAllSelected, | |||
indeterminate: this.isSomeSelected, | |||
} | |||
}, | |||
isAllSelected() { | |||
return this.selectedFiles.length === this.nodes.length | |||
}, | |||
isNoneSelected() { | |||
return this.selectedFiles.length === 0 | |||
}, | |||
isSomeSelected() { | |||
return !this.isAllSelected && !this.isNoneSelected | |||
}, | |||
selectedFiles() { | |||
return this.$store.state.selection.selected | |||
}, | |||
}, | |||
methods: { | |||
/** | |||
* Get a cached note from the store | |||
* | |||
* @param {number} fileId the file id to get | |||
* @return {Folder|File} | |||
*/ | |||
getNode(fileId) { | |||
return this.$store.getters['files/getNode'](fileId) | |||
}, | |||
onToggleAll(selected) { | |||
if (selected) { | |||
const selection = this.nodes.map(node => node.attributes.fileid.toString()) | |||
logger.debug('Added all nodes to selection', { selection }) | |||
this.$store.dispatch('selection/set', selection) | |||
} else { | |||
logger.debug('Cleared selection') | |||
this.$store.dispatch('selection/reset') | |||
} | |||
}, | |||
t: translate, | |||
}, | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
@import '../mixins/fileslist-row.scss' | |||
</style> |
@@ -0,0 +1,124 @@ | |||
<!-- | |||
- @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> | |||
- | |||
- @author Gary Kim <gary@garykim.dev> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
- This program is free software: you can redistribute it and/or modify | |||
- it under the terms of the GNU Affero General Public License as | |||
- published by the Free Software Foundation, either version 3 of the | |||
- License, or (at your option) any later version. | |||
- | |||
- This program is distributed in the hope that it will be useful, | |||
- but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
- GNU Affero General Public License for more details. | |||
- | |||
- You should have received a copy of the GNU Affero General Public License | |||
- along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
- | |||
--> | |||
<template> | |||
<VirtualList class="files-list" | |||
:data-component="FileEntry" | |||
:data-key="getFileId" | |||
:data-sources="nodes" | |||
:estimate-size="55" | |||
:table-mode="true" | |||
item-class="files-list__row" | |||
wrap-class="files-list__body"> | |||
<template #before> | |||
<caption v-show="false" class="files-list__caption"> | |||
{{ summary }} | |||
</caption> | |||
</template> | |||
<template #header> | |||
<FilesListHeader :nodes="nodes" /> | |||
</template> | |||
</VirtualList> | |||
</template> | |||
<script lang="ts"> | |||
import { Folder, File } from '@nextcloud/files' | |||
import { translate, translatePlural } from '@nextcloud/l10n' | |||
import VirtualList from 'vue-virtual-scroll-list' | |||
import FileEntry from './FileEntry.vue' | |||
import FilesListHeader from './FilesListHeader.vue' | |||
export default { | |||
name: 'FilesListVirtual', | |||
components: { | |||
VirtualList, | |||
FilesListHeader, | |||
}, | |||
props: { | |||
nodes: { | |||
type: [File, Folder], | |||
required: true, | |||
}, | |||
}, | |||
data() { | |||
return { | |||
FileEntry, | |||
} | |||
}, | |||
computed: { | |||
files() { | |||
return this.nodes.filter(node => node.type === 'file') | |||
}, | |||
summaryFile() { | |||
const count = this.files.length | |||
return translatePlural('files', '{count} file', '{count} files', count, { count }) | |||
}, | |||
summaryFolder() { | |||
const count = this.nodes.length - this.files.length | |||
return translatePlural('files', '{count} folder', '{count} folders', count, { count }) | |||
}, | |||
summary() { | |||
return translate('files', '{summaryFile} and {summaryFolder}', this) | |||
}, | |||
}, | |||
methods: { | |||
getFileId(node) { | |||
return node.attributes.fileid | |||
}, | |||
t: translate, | |||
}, | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
.files-list { | |||
--row-height: 55px; | |||
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2); | |||
--checkbox-size: 24px; | |||
--clickable-area: 44px; | |||
--icon-preview-size: 32px; | |||
display: block; | |||
overflow: auto; | |||
height: 100%; | |||
&::v-deep { | |||
tbody, thead, tfoot { | |||
display: flex; | |||
flex-direction: column; | |||
width: 100%; | |||
} | |||
thead, .files-list__row { | |||
border-bottom: 1px solid var(--color-border); | |||
} | |||
} | |||
} | |||
</style> |
@@ -4,12 +4,15 @@ import processLegacyFilesViews from './legacy/navigationMapper.js' | |||
import Vue from 'vue' | |||
import NavigationService from './services/Navigation.ts' | |||
import NavigationView from './views/Navigation.vue' | |||
import FilesListView from './views/FilesList.vue' | |||
import SettingsService from './services/Settings.js' | |||
import SettingsModel from './models/Setting.js' | |||
import router from './router/router.js' | |||
import store from './store/index.ts' | |||
// Init private and public Files namespace | |||
window.OCA.Files = window.OCA.Files ?? {} | |||
@@ -35,5 +38,17 @@ const FilesNavigationRoot = new View({ | |||
}) | |||
FilesNavigationRoot.$mount('#app-navigation-files') | |||
// Init content list view | |||
const ListView = Vue.extend(FilesListView) | |||
const FilesList = new ListView({ | |||
name: 'FilesListRoot', | |||
propsData: { | |||
Navigation, | |||
}, | |||
router, | |||
store, | |||
}) | |||
FilesList.$mount('#app-content-vue') | |||
// Init legacy files views | |||
processLegacyFilesViews() |
@@ -0,0 +1,63 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
td, th { | |||
height: var(--row-height); | |||
vertical-align: middle; | |||
padding: 0px; | |||
border: none; | |||
} | |||
.files-list__row-checkbox { | |||
width: var(--row-height); | |||
&::v-deep .checkbox-radio-switch { | |||
--icon-size: var(--checkbox-size); | |||
display: flex; | |||
justify-content: center; | |||
label.checkbox-radio-switch__label { | |||
margin: 0; | |||
height: var(--clickable-area); | |||
width: var(--clickable-area); | |||
padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2) | |||
} | |||
.checkbox-radio-switch__icon { | |||
margin: 0 !important; | |||
} | |||
} | |||
} | |||
.files-list__row-icon { | |||
// Remove left padding to look nicer with the checkbox | |||
// => ico preview size + one checkbox td padding | |||
width: calc(var(--icon-preview-size) + var(--checkbox-padding)); | |||
padding-right: var(--checkbox-padding); | |||
color: var(--color-primary-element); | |||
& > span { | |||
justify-content: flex-start; | |||
} | |||
&::v-deep svg { | |||
width: var(--icon-preview-size); | |||
height: var(--icon-preview-size); | |||
} | |||
} |
@@ -19,19 +19,27 @@ | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
import type Node from '@nextcloud/files/dist/files/node' | |||
/* eslint-disable */ | |||
import type { Folder, Node } from '@nextcloud/files' | |||
import isSvg from 'is-svg' | |||
import logger from '../logger.js' | |||
export type ContentsWithRoot = { | |||
folder: Folder, | |||
contents: Node[] | |||
} | |||
export interface Column { | |||
/** Unique column ID */ | |||
id: string | |||
/** Translated column title */ | |||
title: string | |||
/** Property key from Node main or additional attributes. | |||
Will be used if no custom sort function is provided. | |||
Sorting will be done by localCompare */ | |||
/** | |||
* Property key from Node main or additional attributes. | |||
* Will be used if no custom sort function is provided. | |||
* Sorting will be done by localCompare | |||
*/ | |||
property: string | |||
/** Special function used to sort Nodes between them */ | |||
sortFunction?: (nodeA: Node, nodeB: Node) => number; | |||
@@ -45,8 +53,15 @@ export interface Navigation { | |||
id: string | |||
/** Translated view name */ | |||
name: string | |||
/** Method return the content of the provided path */ | |||
getFiles: (path: string) => Node[] | |||
/** | |||
* Method return the content of the provided path | |||
* This ideally should be a cancellable promise. | |||
* promise.cancel(reason) will be called when the directory | |||
* change and the promise is not resolved yet. | |||
* You _must_ also return the current directory | |||
* information alongside with its content. | |||
*/ | |||
getContents: (path: string) => Promise<ContentsWithRoot[]> | |||
/** The view icon as an inline svg */ | |||
icon: string | |||
/** The view order */ | |||
@@ -150,8 +165,8 @@ const isValidNavigation = function(view: Navigation): boolean { | |||
* TODO: remove when support for legacy views is removed | |||
*/ | |||
if (!view.legacy) { | |||
if (!view.getFiles || typeof view.getFiles !== 'function') { | |||
throw new Error('Navigation getFiles is required and must be a function') | |||
if (!view.getContents || typeof view.getContents !== 'function') { | |||
throw new Error('Navigation getContents is required and must be a function') | |||
} | |||
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { |
@@ -0,0 +1,97 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import type { Folder, Node } from '@nextcloud/files' | |||
import Vue from 'vue' | |||
import type { FileStore, RootStore, RootOptions, Service } from '../types' | |||
const state = { | |||
files: {} as FileStore, | |||
roots: {} as RootStore, | |||
} | |||
const getters = { | |||
/** | |||
* Get a file or folder by id | |||
*/ | |||
getNode: (state) => (id: number): Node|undefined => state.files[id], | |||
/** | |||
* Get a list of files or folders by their IDs | |||
* Does not return undefined values | |||
*/ | |||
getNodes: (state) => (ids: number[]): Node[] => ids | |||
.map(id => state.files[id]) | |||
.filter(Boolean), | |||
/** | |||
* Get a file or folder by id | |||
*/ | |||
getRoot: (state) => (service: Service): Folder|undefined => state.roots[service], | |||
} | |||
const mutations = { | |||
updateNodes: (state, nodes: Node[]) => { | |||
nodes.forEach(node => { | |||
if (!node.attributes.fileid) { | |||
return | |||
} | |||
Vue.set(state.files, node.attributes.fileid, node) | |||
// state.files = { | |||
// ...state.files, | |||
// [node.attributes.fileid]: node, | |||
// } | |||
}) | |||
}, | |||
setRoot: (state, { service, root }: RootOptions) => { | |||
state.roots = { | |||
...state.roots, | |||
[service]: root, | |||
} | |||
} | |||
} | |||
const actions = { | |||
/** | |||
* Insert valid nodes into the store. | |||
* Roots (that does _not_ have a fileid) should | |||
* be defined in the roots store | |||
*/ | |||
addNodes: (context, nodes: Node[]) => { | |||
context.commit('updateNodes', nodes) | |||
}, | |||
/** | |||
* Set the root of a service | |||
*/ | |||
setRoot(context, { service, root }: RootOptions) { | |||
context.commit('setRoot', { service, root }) | |||
} | |||
} | |||
export default { | |||
namespaced: true, | |||
state, | |||
getters, | |||
mutations, | |||
actions, | |||
} |
@@ -0,0 +1,16 @@ | |||
import Vue from 'vue' | |||
import Vuex, { Store } from 'vuex' | |||
import files from './files' | |||
import paths from './paths' | |||
import selection from './selection' | |||
Vue.use(Vuex) | |||
export default new Store({ | |||
modules: { | |||
files, | |||
paths, | |||
selection, | |||
}, | |||
}) |
@@ -0,0 +1,71 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import type { Folder } from '@nextcloud/files' | |||
import Vue from 'vue' | |||
import type { PathOptions, ServicePaths, ServiceStore } from '../types' | |||
const module = { | |||
state: { | |||
services: { | |||
files: {} as ServicePaths, | |||
} as ServiceStore, | |||
}, | |||
getters: { | |||
getPath(state: { services: ServiceStore }) { | |||
return (service: string, path: string): number|undefined => { | |||
if (!state.services[service]) { | |||
return undefined | |||
} | |||
return state.services[service][path] | |||
} | |||
}, | |||
}, | |||
mutations: { | |||
addPath: (state, opts: PathOptions) => { | |||
// If it doesn't exists, init the service state | |||
if (!state.services[opts.service]) { | |||
// TODO: investigate why Vue.set is not working | |||
state.services = { | |||
[opts.service]: {} as ServicePaths, | |||
...state.services | |||
} | |||
} | |||
// Now we can set the path | |||
Vue.set(state.services[opts.service], opts.path, opts.fileid) | |||
} | |||
}, | |||
actions: { | |||
addPath: (context, opts: PathOptions) => { | |||
context.commit('addPath', opts) | |||
}, | |||
} | |||
} | |||
export default { | |||
namespaced: true, | |||
...module, | |||
} |
@@ -0,0 +1,51 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import type { Folder } from '@nextcloud/files' | |||
import Vue from 'vue' | |||
import type { PathOptions, ServicePaths, ServiceStore } from '../types' | |||
const module = { | |||
state: { | |||
selected: [] as number[] | |||
}, | |||
mutations: { | |||
set: (state, selection: number[]) => { | |||
Vue.set(state, 'selected', selection) | |||
} | |||
}, | |||
actions: { | |||
set: (context, selection = [] as number[]) => { | |||
context.commit('set', selection) | |||
}, | |||
reset(context) { | |||
context.commit('set', []) | |||
} | |||
} | |||
} | |||
export default { | |||
namespaced: true, | |||
...module, | |||
} |
@@ -0,0 +1,56 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import type { Folder } from '@nextcloud/files' | |||
import type { Node } from '@nextcloud/files' | |||
// Global definitions | |||
export type Service = string | |||
// Files store | |||
export type FileStore = { | |||
[id: number]: Node | |||
} | |||
export type RootStore = { | |||
[service: Service]: Folder | |||
} | |||
export interface RootOptions { | |||
root: Folder | |||
service: Service | |||
} | |||
// Paths store | |||
export type ServicePaths = { | |||
[path: string]: number | |||
} | |||
export type ServiceStore = { | |||
[service: Service]: ServicePaths | |||
} | |||
export interface PathOptions { | |||
service: Service | |||
path: string | |||
fileid: number | |||
} |
@@ -0,0 +1,318 @@ | |||
<!-- | |||
- @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev> | |||
- | |||
- @author Gary Kim <gary@garykim.dev> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
- This program is free software: you can redistribute it and/or modify | |||
- it under the terms of the GNU Affero General Public License as | |||
- published by the Free Software Foundation, either version 3 of the | |||
- License, or (at your option) any later version. | |||
- | |||
- This program is distributed in the hope that it will be useful, | |||
- but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
- GNU Affero General Public License for more details. | |||
- | |||
- You should have received a copy of the GNU Affero General Public License | |||
- along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
- | |||
--> | |||
<template> | |||
<NcAppContent v-show="!currentView?.legacy" | |||
:class="{'app-content--hidden': currentView?.legacy}" | |||
data-cy-files-content> | |||
<div class="files-list__header"> | |||
<!-- Current folder breadcrumbs --> | |||
<BreadCrumbs :path="dir" /> | |||
<!-- Secondary loading indicator --> | |||
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" /> | |||
</div> | |||
<!-- Initial loading --> | |||
<NcLoadingIcon v-if="loading && !isRefreshing" | |||
class="files-list__loading-icon" | |||
:size="38" | |||
:title="t('files', 'Loading current folder')" /> | |||
<!-- Empty content placeholder --> | |||
<NcEmptyContent v-else-if="!loading && isEmptyDir" | |||
:title="t('files', 'No files in here')" | |||
:description="t('files', 'No files or folders have been deleted yet')" | |||
data-cy-files-content-empty> | |||
<template #action> | |||
<NcButton v-if="dir !== '/'" | |||
aria-label="t('files', 'Go to the previous folder')" | |||
type="primary" | |||
:to="toPreviousDir"> | |||
{{ t('files', 'Go back') }} | |||
</NcButton> | |||
</template> | |||
<template #icon> | |||
<TrashCan /> | |||
</template> | |||
</NcEmptyContent> | |||
<!-- File list --> | |||
<FilesListVirtual v-else :nodes="dirContents" /> | |||
</NcAppContent> | |||
</template> | |||
<script lang="ts"> | |||
import { Folder } from '@nextcloud/files' | |||
import { translate } from '@nextcloud/l10n' | |||
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' | |||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' | |||
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' | |||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' | |||
import TrashCan from 'vue-material-design-icons/TrashCan.vue' | |||
import BreadCrumbs from '../components/BreadCrumbs.vue' | |||
import logger from '../logger.js' | |||
import Navigation from '../services/Navigation' | |||
import FilesListVirtual from '../components/FilesListVirtual.vue' | |||
import { ContentsWithRoot } from '../services/Navigation' | |||
import { join } from 'path' | |||
export default { | |||
name: 'FilesList', | |||
components: { | |||
BreadCrumbs, | |||
FilesListVirtual, | |||
NcAppContent, | |||
NcButton, | |||
NcEmptyContent, | |||
NcLoadingIcon, | |||
TrashCan, | |||
}, | |||
props: { | |||
// eslint-disable-next-line vue/prop-name-casing | |||
Navigation: { | |||
type: Navigation, | |||
required: true, | |||
}, | |||
}, | |||
data() { | |||
return { | |||
loading: true, | |||
promise: null, | |||
} | |||
}, | |||
computed: { | |||
currentViewId() { | |||
return this.$route.params.view || 'files' | |||
}, | |||
/** @return {Navigation} */ | |||
currentView() { | |||
return this.views.find(view => view.id === this.currentViewId) | |||
}, | |||
/** @return {Navigation[]} */ | |||
views() { | |||
return this.Navigation.views | |||
}, | |||
/** | |||
* The current directory query. | |||
* @return {string} | |||
*/ | |||
dir() { | |||
// Remove any trailing slash but leave root slash | |||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') | |||
}, | |||
/** | |||
* The current folder. | |||
* @return {Folder|undefined} | |||
*/ | |||
currentFolder() { | |||
if (this.dir === '/') { | |||
return this.$store.getters['files/getRoot'](this.currentViewId) | |||
} | |||
const fileId = this.$store.getters['paths/getPath'](this.currentViewId, this.dir) | |||
return this.$store.getters['files/getNode'](fileId) | |||
}, | |||
/** | |||
* The current directory contents. | |||
* @return {Node[]} | |||
*/ | |||
dirContents() { | |||
return (this.currentFolder?.children || []).map(this.getNode) | |||
}, | |||
/** | |||
* The current directory is empty. | |||
*/ | |||
isEmptyDir() { | |||
return this.dirContents.length === 0 | |||
}, | |||
/** | |||
* We are refreshing the current directory. | |||
* But we already have a cached version of it | |||
* that is not empty. | |||
*/ | |||
isRefreshing() { | |||
return this.currentFolder !== undefined | |||
&& !this.isEmptyDir | |||
&& this.loading | |||
}, | |||
/** | |||
* Route to the previous directory. | |||
*/ | |||
toPreviousDir() { | |||
const dir = this.dir.split('/').slice(0, -1).join('/') || '/' | |||
return { ...this.$route, query: { dir } } | |||
}, | |||
}, | |||
watch: { | |||
currentView(newView, oldView) { | |||
if (newView?.id === oldView?.id) { | |||
return | |||
} | |||
logger.debug('View changed', { newView, oldView }) | |||
this.$store.dispatch('selection/reset') | |||
this.fetchContent() | |||
}, | |||
dir(newDir, oldDir) { | |||
logger.debug('Directory changed', { newDir, oldDir }) | |||
// TODO: preserve selection on browsing? | |||
this.$store.dispatch('selection/reset') | |||
this.fetchContent() | |||
}, | |||
paths(paths) { | |||
logger.debug('Paths changed', { paths }) | |||
}, | |||
currentFolder(currentFolder) { | |||
logger.debug('currentFolder changed', { currentFolder }) | |||
}, | |||
}, | |||
methods: { | |||
async fetchContent() { | |||
if (this.currentView?.legacy) { | |||
return | |||
} | |||
this.loading = true | |||
const dir = this.dir | |||
const currentView = this.currentView | |||
// If we have a cancellable promise ongoing, cancel it | |||
if (typeof this.promise?.cancel === 'function') { | |||
this.promise.cancel() | |||
logger.debug('Cancelled previous ongoing fetch') | |||
} | |||
// Fetch the current dir contents | |||
/** @type {Promise<ContentsWithRoot>} */ | |||
this.promise = currentView.getContents(dir) | |||
try { | |||
const { folder, contents } = await this.promise | |||
logger.debug('Fetched contents', { dir, folder, contents }) | |||
// Update store | |||
this.$store.dispatch('files/addNodes', contents) | |||
// Define current directory children | |||
folder.children = contents.map(node => node.attributes.fileid) | |||
// If we're in the root dir, define the root | |||
if (dir === '/') { | |||
console.debug('files', 'Setting root', { service: currentView.id, folder }) | |||
this.$store.dispatch('files/setRoot', { service: currentView.id, root: folder }) | |||
} else | |||
// Otherwise, add the folder to the store | |||
if (folder.attributes.fileid) { | |||
this.$store.dispatch('files/addNodes', [folder]) | |||
this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: folder.attributes.fileid, path: dir }) | |||
} else { | |||
// If we're here, the view API messed up | |||
logger.error('Invalid root folder returned', { dir, folder, currentView }) | |||
} | |||
// Update paths store | |||
const folders = contents.filter(node => node.type === 'folder') | |||
folders.forEach(node => { | |||
this.$store.dispatch('paths/addPath', { service: currentView.id, fileid: node.attributes.fileid, path: join(dir, node.basename) }) | |||
}) | |||
} catch (error) { | |||
logger.error('Error while fetching content', { error }) | |||
} finally { | |||
this.loading = false | |||
} | |||
}, | |||
/** | |||
* Get a cached note from the store | |||
* | |||
* @param {number} fileId the file id to get | |||
* @return {Folder|File} | |||
*/ | |||
getNode(fileId) { | |||
return this.$store.getters['files/getNode'](fileId) | |||
}, | |||
t: translate, | |||
}, | |||
} | |||
</script> | |||
<style scoped lang="scss"> | |||
.app-content { | |||
// Virtual list needs to be full height and is scrollable | |||
display: flex; | |||
overflow: hidden; | |||
flex-direction: column; | |||
max-height: 100%; | |||
// TODO: remove after all legacy views are migrated | |||
// Hides the legacy app-content if shown view is not legacy | |||
&:not(&--hidden)::v-deep + #app-content { | |||
display: none; | |||
} | |||
} | |||
$margin: 4px; | |||
$navigationToggleSize: 50px; | |||
.files-list { | |||
&__header { | |||
display: flex; | |||
align-content: center; | |||
// Do not grow or shrink (vertically) | |||
flex: 0 0; | |||
// Align with the navigation toggle icon | |||
margin: $margin $margin $margin $navigationToggleSize; | |||
> * { | |||
// Do not grow or shrink (horizontally) | |||
// Only the breadcrumbs shrinks | |||
flex: 0 0; | |||
} | |||
} | |||
&__refresh-icon { | |||
flex: 0 0 44px; | |||
width: 44px; | |||
height: 44px; | |||
} | |||
&__loading-icon { | |||
margin: auto; | |||
} | |||
} | |||
</style> |
@@ -32,13 +32,20 @@ | |||
:title="view.name" | |||
:to="generateToNavigation(view)" | |||
@update:open="onToggleExpand(view)"> | |||
<!-- Sanitized icon as svg if provided --> | |||
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> | |||
<!-- Child views if any --> | |||
<NcAppNavigationItem v-for="child in childViews[view.id]" | |||
:key="child.id" | |||
:data-cy-files-navigation-item="child.id" | |||
:exact="true" | |||
:icon="child.iconClass" | |||
:title="child.name" | |||
:to="generateToNavigation(child)" /> | |||
:to="generateToNavigation(child)"> | |||
<!-- Sanitized icon as svg if provided --> | |||
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> | |||
</NcAppNavigationItem> | |||
</NcAppNavigationItem> | |||
</template> | |||
@@ -74,6 +81,7 @@ import axios from '@nextcloud/axios' | |||
import Cog from 'vue-material-design-icons/Cog.vue' | |||
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' | |||
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' | |||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | |||
import logger from '../logger.js' | |||
import Navigation from '../services/Navigation.ts' | |||
@@ -86,10 +94,11 @@ export default { | |||
components: { | |||
Cog, | |||
NavigationQuota, | |||
NcAppNavigation, | |||
NcAppNavigationItem, | |||
NcIconSvgWrapper, | |||
SettingsModal, | |||
NavigationQuota, | |||
}, | |||
props: { | |||
@@ -151,7 +160,16 @@ export default { | |||
watch: { | |||
currentView(view, oldView) { | |||
logger.debug('View changed', { id: view.id, view }) | |||
// If undefined, it means we're initializing the view | |||
// This is handled by the legacy-view:initialized event | |||
if (view?.id === oldView?.id) { | |||
return | |||
} | |||
this.Navigation.setActive(view.id) | |||
logger.debug('Navigation changed', { id: view.id, view }) | |||
// debugger | |||
this.showView(view, oldView) | |||
}, | |||
}, | |||
@@ -163,6 +181,12 @@ export default { | |||
} | |||
subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged) | |||
// TODO: remove this once the legacy navigation is gone | |||
subscribe('files:legacy-view:initialized', () => { | |||
logger.debug('Legacy view initialized', { ...this.currentView }) | |||
this.showView(this.currentView) | |||
}) | |||
}, | |||
methods: { | |||
@@ -174,7 +198,7 @@ export default { | |||
// Closing any opened sidebar | |||
window?.OCA?.Files?.Sidebar?.close?.() | |||
if (view.legacy) { | |||
if (view?.legacy) { | |||
const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer') | |||
document.querySelectorAll('#app-content .viewcontainer').forEach(el => { | |||
el.classList.add('hidden') | |||
@@ -188,7 +212,6 @@ export default { | |||
logger.debug('Triggering legacy navigation event', params) | |||
window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params)) | |||
window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params)) | |||
} | |||
this.Navigation.setActive(view) |
@@ -1,13 +1,11 @@ | |||
<div id="app-navigation-files" role="navigation"></div> | |||
<div class="hidden"> | |||
<ul class="with-icon" tabindex="0"> | |||
<?php | |||
$pinned = 0; | |||
foreach ($_['navigationItems'] as $item) { | |||
$pinned = NavigationListElements($item, $l, $pinned); | |||
} | |||
$pinned = 0; | |||
foreach ($_['navigationItems'] as $item) { | |||
$pinned = NavigationListElements($item, $l, $pinned); | |||
} | |||
?> | |||
</ul> | |||
</div> |
@@ -1,5 +1,9 @@ | |||
<?php /** @var \OCP\IL10N $l */ ?> | |||
<?php $_['appNavigation']->printPage(); ?> | |||
<!-- New files vue container --> | |||
<div id="app-content-vue" class="hidden"></div> | |||
<div id="app-content" tabindex="0"> | |||
<input type="checkbox" class="hidden-visually" id="showgridview" | |||
@@ -8,8 +12,6 @@ | |||
<label id="view-toggle" for="showgridview" tabindex="0" class="button <?php p($_['showgridview'] ? 'icon-toggle-filelist' : 'icon-toggle-pictures') ?>" | |||
title="<?php p($_['showgridview'] ? $l->t('Show list view') : $l->t('Show grid view'))?>"></label> | |||
<!-- New files vue container --> | |||
<div id="app-content-vue" class="hidden"></div> | |||
<!-- Legacy views --> | |||
<?php foreach ($_['appContents'] as $content) { ?> |
@@ -21,6 +21,7 @@ return array( | |||
'OCA\\Files_Trashbin\\Expiration' => $baseDir . '/../lib/Expiration.php', | |||
'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php', | |||
'OCA\\Files_Trashbin\\Hooks' => $baseDir . '/../lib/Hooks.php', | |||
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => $baseDir . '/../lib/Listeners/LoadAdditionalScripts.php', | |||
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => $baseDir . '/../lib/Migration/Version1010Date20200630192639.php', | |||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => $baseDir . '/../lib/Sabre/AbstractTrash.php', | |||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => $baseDir . '/../lib/Sabre/AbstractTrashFile.php', |
@@ -36,6 +36,7 @@ class ComposerStaticInitFiles_Trashbin | |||
'OCA\\Files_Trashbin\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php', | |||
'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php', | |||
'OCA\\Files_Trashbin\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php', | |||
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScripts.php', | |||
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630192639.php', | |||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrash.php', | |||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrashFile.php', |
@@ -26,8 +26,10 @@ | |||
namespace OCA\Files_Trashbin\AppInfo; | |||
use OCA\DAV\Connector\Sabre\Principal; | |||
use OCA\Files\Event\LoadAdditionalScriptsEvent; | |||
use OCA\Files_Trashbin\Capabilities; | |||
use OCA\Files_Trashbin\Expiration; | |||
use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts; | |||
use OCA\Files_Trashbin\Trash\ITrashManager; | |||
use OCA\Files_Trashbin\Trash\TrashManager; | |||
use OCA\Files_Trashbin\UserMigration\TrashbinMigrator; | |||
@@ -55,6 +57,11 @@ class Application extends App implements IBootstrap { | |||
$context->registerServiceAlias('principalBackend', Principal::class); | |||
$context->registerUserMigrator(TrashbinMigrator::class); | |||
$context->registerEventListener( | |||
LoadAdditionalScriptsEvent::class, | |||
LoadAdditionalScripts::class | |||
); | |||
} | |||
public function boot(IBootContext $context): void { | |||
@@ -68,18 +75,6 @@ class Application extends App implements IBootstrap { | |||
\OCP\Util::connectHook('OC_Filesystem', 'post_write', 'OCA\Files_Trashbin\Hooks', 'post_write_hook'); | |||
// pre and post-rename, disable trash logic for the copy+unlink case | |||
\OCP\Util::connectHook('OC_Filesystem', 'delete', 'OCA\Files_Trashbin\Trashbin', 'ensureFileScannedHook'); | |||
\OCA\Files\App::getNavigationManager()->add(function () { | |||
$l = \OC::$server->getL10N(self::APP_ID); | |||
return [ | |||
'id' => 'trashbin', | |||
'appname' => self::APP_ID, | |||
'script' => 'list.php', | |||
'order' => 50, | |||
'name' => $l->t('Deleted files'), | |||
'classes' => 'pinned', | |||
]; | |||
}); | |||
} | |||
public function registerTrashBackends(IServerContainer $serverContainer, ILogger $logger, IAppManager $appManager, ITrashManager $trashManager) { |
@@ -0,0 +1,41 @@ | |||
<?php | |||
declare(strict_types=1); | |||
/** | |||
* @copyright Copyright (c) 2022, John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license GNU AGPL version 3 or any later version | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
namespace OCA\Files_Trashbin\Listeners; | |||
use OCA\Files_Trashbin\AppInfo\Application; | |||
use OCA\Files\Event\LoadAdditionalScriptsEvent; | |||
use OCP\EventDispatcher\Event; | |||
use OCP\EventDispatcher\IEventListener; | |||
use OCP\Util; | |||
class LoadAdditionalScripts implements IEventListener { | |||
public function handle(Event $event): void { | |||
if (!($event instanceof LoadAdditionalScriptsEvent)) { | |||
return; | |||
} | |||
Util::addScript(Application::APP_ID, 'main'); | |||
} | |||
} |
@@ -0,0 +1,39 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
import type NavigationService from '../../files/src/services/Navigation' | |||
import { translate as t } from '@nextcloud/l10n' | |||
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw' | |||
import getContents from './services/trashbin' | |||
const Navigation = window.OCP.Files.Navigation as NavigationService | |||
Navigation.register({ | |||
id: 'trashbin', | |||
name: t('files_trashbin', 'Deleted files'), | |||
icon: DeleteSvg, | |||
order: 50, | |||
sticky: true, | |||
getContents, | |||
}) |
@@ -0,0 +1,33 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
import { createClient } from 'webdav' | |||
import { generateRemoteUrl } from '@nextcloud/router' | |||
import { getCurrentUser, getRequestToken } from '@nextcloud/auth' | |||
export const rootPath = `/trashbin/${getCurrentUser()?.uid}/trash` | |||
export const rootUrl = generateRemoteUrl('dav' + rootPath) | |||
const client = createClient(rootUrl, { | |||
headers: { | |||
requesttoken: getRequestToken(), | |||
}, | |||
}) | |||
export default client |
@@ -0,0 +1,95 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import { getCurrentUser } from '@nextcloud/auth' | |||
import { File, Folder, parseWebdavPermissions } from '@nextcloud/files' | |||
import { generateRemoteUrl } from '@nextcloud/router' | |||
import type { FileStat, ResponseDataDetailed } from 'webdav' | |||
import type { ContentsWithRoot } from '../../../files/src/services/Navigation' | |||
import client, { rootPath } from './client' | |||
const data = `<?xml version="1.0"?> | |||
<d:propfind xmlns:d="DAV:" | |||
xmlns:oc="http://owncloud.org/ns" | |||
xmlns:nc="http://nextcloud.org/ns"> | |||
<d:prop> | |||
<nc:trashbin-filename /> | |||
<nc:trashbin-deletion-time /> | |||
<nc:trashbin-original-location /> | |||
<nc:trashbin-title /> | |||
<d:getlastmodified /> | |||
<d:getetag /> | |||
<d:getcontenttype /> | |||
<d:resourcetype /> | |||
<oc:fileid /> | |||
<oc:permissions /> | |||
<oc:size /> | |||
<d:getcontentlength /> | |||
</d:prop> | |||
</d:propfind>` | |||
const resultToNode = function(node: FileStat): File | Folder { | |||
const permissions = parseWebdavPermissions(node.props?.permissions) | |||
const owner = getCurrentUser()?.uid as string | |||
const nodeData = { | |||
id: node.props?.fileid as number || 0, | |||
source: generateRemoteUrl('dav' + rootPath + node.filename), | |||
mtime: new Date(node.lastmod), | |||
mime: node.mime as string, | |||
size: node.props?.size as number || 0, | |||
permissions, | |||
owner, | |||
root: rootPath, | |||
attributes: { | |||
...node, | |||
...node.props, | |||
// Override displayed name on the list | |||
displayName: node.props?.['trashbin-filename'], | |||
}, | |||
} | |||
return node.type === 'file' | |||
? new File(nodeData) | |||
: new Folder(nodeData) | |||
} | |||
export default async (path: string = '/'): Promise<ContentsWithRoot> => { | |||
// TODO: use only one request when webdav-client supports it | |||
// @see https://github.com/perry-mitchell/webdav-client/pull/334 | |||
const rootResponse = await client.stat(path, { | |||
details: true, | |||
data, | |||
}) as ResponseDataDetailed<FileStat> | |||
const contentsResponse = await client.getDirectoryContents(path, { | |||
details: true, | |||
data, | |||
}) as ResponseDataDetailed<FileStat[]> | |||
return { | |||
folder: resultToNode(rootResponse.data) as Folder, | |||
contents: contentsResponse.data.map(resultToNode), | |||
} | |||
} |
@@ -1,22 +0,0 @@ | |||
/* | |||
* Copyright (c) 2014 | |||
* | |||
* This file is licensed under the Affero General Public License version 3 | |||
* or later. | |||
* | |||
* See the COPYING-README file. | |||
* | |||
*/ | |||
#app-content-trashbin tbody tr[data-type="file"] td a.name, | |||
#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext, | |||
#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext span { | |||
cursor: default; | |||
} | |||
#app-content-trashbin .summary :last-child { | |||
padding: 0; | |||
} | |||
#app-content-trashbin .files-filestable .summary .filesize { | |||
display: none; | |||
} | |||
@@ -1,70 +0,0 @@ | |||
/** | |||
* @copyright 2014 Vincent Petry <pvince81@owncloud.com> | |||
* | |||
* @author Vincent Petry <vincent@nextcloud.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
describe('OCA.Trashbin.App tests', function() { | |||
var App = OCA.Trashbin.App; | |||
beforeEach(function() { | |||
$('#testArea').append( | |||
'<div id="app-navigation">' + | |||
'<ul><li data-id="files"><a>Files</a></li>' + | |||
'<li data-id="trashbin"><a>Trashbin</a></li>' + | |||
'</div>' + | |||
'<div id="app-content">' + | |||
'<div id="app-content-files" class="hidden">' + | |||
'</div>' + | |||
'<div id="app-content-trashbin" class="hidden">' + | |||
'</div>' + | |||
'</div>' + | |||
'</div>' | |||
); | |||
App.initialize($('#app-content-trashbin')); | |||
}); | |||
afterEach(function() { | |||
App._initialized = false; | |||
App.fileList = null; | |||
}); | |||
describe('initialization', function() { | |||
it('creates a custom filelist instance', function() { | |||
App.initialize(); | |||
expect(App.fileList).toBeDefined(); | |||
expect(App.fileList.$el.is('#app-content-trashbin')).toEqual(true); | |||
}); | |||
it('registers custom file actions', function() { | |||
var fileActions; | |||
App.initialize(); | |||
fileActions = App.fileList.fileActions; | |||
expect(fileActions.actions.all).toBeDefined(); | |||
expect(fileActions.actions.all.Restore).toBeDefined(); | |||
expect(fileActions.actions.all.Delete).toBeDefined(); | |||
expect(fileActions.actions.all.Rename).not.toBeDefined(); | |||
expect(fileActions.actions.all.Download).not.toBeDefined(); | |||
expect(fileActions.defaults.dir).toEqual('Open'); | |||
}); | |||
}); | |||
}); |
@@ -1,397 +0,0 @@ | |||
/** | |||
* @copyright 2014 Vincent Petry <pvince81@owncloud.com> | |||
* | |||
* @author Abijeet <abijeetpatro@gmail.com> | |||
* @author Christoph Wurst <christoph@winzerhof-wurst.at> | |||
* @author Jan C. Borchardt <hey@jancborchardt.net> | |||
* @author Jan-Christoph Borchardt <hey@jancborchardt.net> | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* @author Robin Appelman <robin@icewind.nl> | |||
* @author Vincent Petry <vincent@nextcloud.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
describe('OCA.Trashbin.FileList tests', function () { | |||
var testFiles, alertStub, notificationStub, fileList, client; | |||
beforeEach(function () { | |||
alertStub = sinon.stub(OC.dialogs, 'alert'); | |||
notificationStub = sinon.stub(OC.Notification, 'show'); | |||
client = new OC.Files.Client({ | |||
host: 'localhost', | |||
port: 80, | |||
root: '/remote.php/dav/trashbin/user', | |||
useHTTPS: OC.getProtocol() === 'https' | |||
}); | |||
// init parameters and test table elements | |||
$('#testArea').append( | |||
'<div id="app-content">' + | |||
// set this but it shouldn't be used (could be the one from the | |||
// files app) | |||
'<input type="hidden" id="permissions" value="31"></input>' + | |||
// dummy controls | |||
'<div class="files-controls">' + | |||
' <div class="actions creatable"></div>' + | |||
' <div class="notCreatable"></div>' + | |||
'</div>' + | |||
// dummy table | |||
// TODO: at some point this will be rendered by the fileList class itself! | |||
'<table class="files-filestable list-container view-grid">' + | |||
'<thead><tr><th class="hidden column-name">' + | |||
'<input type="checkbox" id="select_all_trash" class="select-all">' + | |||
'<span class="name">Name</span>' + | |||
'<span class="selectedActions hidden">' + | |||
'<a href="" class="actions-selected"><span class="icon icon-more"></span><span>Actions</span>' + | |||
'</span>' + | |||
'</th></tr></thead>' + | |||
'<tbody class="files-fileList"></tbody>' + | |||
'<tfoot></tfoot>' + | |||
'</table>' + | |||
'<div class="emptyfilelist emptycontent">Empty content message</div>' + | |||
'</div>' | |||
); | |||
testFiles = [{ | |||
id: 1, | |||
type: 'file', | |||
name: 'One.txt.d11111', | |||
displayName: 'One.txt', | |||
mtime: 11111000, | |||
mimetype: 'text/plain', | |||
etag: 'abc' | |||
}, { | |||
id: 2, | |||
type: 'file', | |||
name: 'Two.jpg.d22222', | |||
displayName: 'Two.jpg', | |||
mtime: 22222000, | |||
mimetype: 'image/jpeg', | |||
etag: 'def', | |||
}, { | |||
id: 3, | |||
type: 'file', | |||
name: 'Three.pdf.d33333', | |||
displayName: 'Three.pdf', | |||
mtime: 33333000, | |||
mimetype: 'application/pdf', | |||
etag: '123', | |||
}, { | |||
id: 4, | |||
type: 'dir', | |||
mtime: 99999000, | |||
name: 'somedir.d99999', | |||
displayName: 'somedir', | |||
mimetype: 'httpd/unix-directory', | |||
etag: '456' | |||
}]; | |||
// register file actions like the trashbin App does | |||
var fileActions = OCA.Trashbin.App._createFileActions(fileList); | |||
fileList = new OCA.Trashbin.FileList( | |||
$('#app-content'), { | |||
fileActions: fileActions, | |||
multiSelectMenu: [{ | |||
name: 'restore', | |||
displayName: t('files', 'Restore'), | |||
iconClass: 'icon-history', | |||
}, | |||
{ | |||
name: 'delete', | |||
displayName: t('files', 'Delete'), | |||
iconClass: 'icon-delete', | |||
} | |||
], | |||
client: client | |||
} | |||
); | |||
}); | |||
afterEach(function () { | |||
testFiles = undefined; | |||
fileList.destroy(); | |||
fileList = undefined; | |||
notificationStub.restore(); | |||
alertStub.restore(); | |||
}); | |||
describe('Initialization', function () { | |||
it('Sorts by mtime by default', function () { | |||
expect(fileList._sort).toEqual('mtime'); | |||
expect(fileList._sortDirection).toEqual('desc'); | |||
}); | |||
it('Always returns read and delete permission', function () { | |||
expect(fileList.getDirectoryPermissions()).toEqual(OC.PERMISSION_READ | OC.PERMISSION_DELETE); | |||
}); | |||
}); | |||
describe('Breadcrumbs', function () { | |||
beforeEach(function () { | |||
var data = { | |||
status: 'success', | |||
data: { | |||
files: testFiles, | |||
permissions: 1 | |||
} | |||
}; | |||
fakeServer.respondWith(/\/index\.php\/apps\/files_trashbin\/ajax\/list.php\?dir=%2Fsubdir/, [ | |||
200, { | |||
"Content-Type": "application/json" | |||
}, | |||
JSON.stringify(data) | |||
]); | |||
}); | |||
it('links the breadcrumb to the trashbin view', function () { | |||
fileList.changeDirectory('/subdir', false, true); | |||
fakeServer.respond(); | |||
var $crumbs = fileList.$el.find('.files-controls .crumb'); | |||
expect($crumbs.length).toEqual(3); | |||
expect($crumbs.eq(1).find('a').text()).toEqual('Home'); | |||
expect($crumbs.eq(1).find('a').attr('href')) | |||
.toEqual(OC.getRootPath() + '/index.php/apps/files?view=trashbin&dir=/'); | |||
expect($crumbs.eq(2).find('a').text()).toEqual('subdir'); | |||
expect($crumbs.eq(2).find('a').attr('href')) | |||
.toEqual(OC.getRootPath() + '/index.php/apps/files?view=trashbin&dir=/subdir'); | |||
}); | |||
}); | |||
describe('Rendering rows', function () { | |||
it('renders rows with the correct data when in root', function () { | |||
// dir listing is false when in root | |||
fileList.setFiles(testFiles); | |||
var $rows = fileList.$el.find('tbody tr'); | |||
var $tr = $rows.eq(0); | |||
expect($rows.length).toEqual(4); | |||
expect($tr.attr('data-id')).toEqual('1'); | |||
expect($tr.attr('data-type')).toEqual('file'); | |||
expect($tr.attr('data-file')).toEqual('One.txt.d11111'); | |||
expect($tr.attr('data-size')).not.toBeDefined(); | |||
expect($tr.attr('data-etag')).toEqual('abc'); | |||
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete | |||
expect($tr.attr('data-mime')).toEqual('text/plain'); | |||
expect($tr.attr('data-mtime')).toEqual('11111000'); | |||
expect($tr.find('a.name').attr('href')).toEqual('#'); | |||
expect($tr.find('.nametext').text().trim()).toEqual('One.txt'); | |||
expect(fileList.findFileEl('One.txt.d11111')[0]).toEqual($tr[0]); | |||
}); | |||
it('renders rows with the correct data when in root after calling setFiles with the same data set', function () { | |||
// dir listing is false when in root | |||
fileList.setFiles(testFiles); | |||
fileList.setFiles(fileList.files); | |||
var $rows = fileList.$el.find('tbody tr'); | |||
var $tr = $rows.eq(0); | |||
expect($rows.length).toEqual(4); | |||
expect($tr.attr('data-id')).toEqual('1'); | |||
expect($tr.attr('data-type')).toEqual('file'); | |||
expect($tr.attr('data-file')).toEqual('One.txt.d11111'); | |||
expect($tr.attr('data-size')).not.toBeDefined(); | |||
expect($tr.attr('data-etag')).toEqual('abc'); | |||
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete | |||
expect($tr.attr('data-mime')).toEqual('text/plain'); | |||
expect($tr.attr('data-mtime')).toEqual('11111000'); | |||
expect($tr.find('a.name').attr('href')).toEqual('#'); | |||
expect($tr.find('.nametext').text().trim()).toEqual('One.txt'); | |||
expect(fileList.findFileEl('One.txt.d11111')[0]).toEqual($tr[0]); | |||
}); | |||
it('renders rows with the correct data when in subdirectory', function () { | |||
fileList.setFiles(testFiles.map(function (file) { | |||
file.name = file.displayName; | |||
return file; | |||
})); | |||
var $rows = fileList.$el.find('tbody tr'); | |||
var $tr = $rows.eq(0); | |||
expect($rows.length).toEqual(4); | |||
expect($tr.attr('data-id')).toEqual('1'); | |||
expect($tr.attr('data-type')).toEqual('file'); | |||
expect($tr.attr('data-file')).toEqual('One.txt'); | |||
expect($tr.attr('data-size')).not.toBeDefined(); | |||
expect($tr.attr('data-etag')).toEqual('abc'); | |||
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete | |||
expect($tr.attr('data-mime')).toEqual('text/plain'); | |||
expect($tr.attr('data-mtime')).toEqual('11111000'); | |||
expect($tr.find('a.name').attr('href')).toEqual('#'); | |||
expect($tr.find('.nametext').text().trim()).toEqual('One.txt'); | |||
expect(fileList.findFileEl('One.txt')[0]).toEqual($tr[0]); | |||
}); | |||
it('does not render a size column', function () { | |||
expect(fileList.$el.find('tbody tr .filesize').length).toEqual(0); | |||
}); | |||
}); | |||
describe('File actions', function () { | |||
describe('Deleting single files', function () { | |||
// TODO: checks ajax call | |||
// TODO: checks spinner | |||
// TODO: remove item after delete | |||
// TODO: bring back item if delete failed | |||
}); | |||
describe('Restoring single files', function () { | |||
// TODO: checks ajax call | |||
// TODO: checks spinner | |||
// TODO: remove item after restore | |||
// TODO: bring back item if restore failed | |||
}); | |||
}); | |||
describe('file previews', function () { | |||
// TODO: check that preview URL is going through files_trashbin | |||
}); | |||
describe('loading file list', function () { | |||
// TODO: check that ajax URL is going through files_trashbin | |||
}); | |||
describe('breadcrumbs', function () { | |||
// TODO: test label + URL | |||
}); | |||
describe('elementToFile', function () { | |||
var $tr; | |||
beforeEach(function () { | |||
fileList.setFiles(testFiles); | |||
$tr = fileList.findFileEl('One.txt.d11111'); | |||
}); | |||
it('converts data attributes to file info structure', function () { | |||
var fileInfo = fileList.elementToFile($tr); | |||
expect(fileInfo.id).toEqual(1); | |||
expect(fileInfo.name).toEqual('One.txt.d11111'); | |||
expect(fileInfo.displayName).toEqual('One.txt'); | |||
expect(fileInfo.mtime).toEqual(11111000); | |||
expect(fileInfo.etag).toEqual('abc'); | |||
expect(fileInfo.permissions).toEqual(OC.PERMISSION_READ | OC.PERMISSION_DELETE); | |||
expect(fileInfo.mimetype).toEqual('text/plain'); | |||
expect(fileInfo.type).toEqual('file'); | |||
}); | |||
}); | |||
describe('Global Actions', function () { | |||
beforeEach(function () { | |||
fileList.setFiles(testFiles); | |||
fileList.findFileEl('One.txt.d11111').find('input:checkbox').click(); | |||
fileList.findFileEl('Three.pdf.d33333').find('input:checkbox').click(); | |||
fileList.findFileEl('somedir.d99999').find('input:checkbox').click(); | |||
fileList.$el.find('.actions-selected').click(); | |||
}); | |||
afterEach(function () { | |||
fileList.$el.find('.actions-selected').click(); | |||
}); | |||
describe('Delete', function () { | |||
it('Shows trashbin actions', function () { | |||
// visible because a few files were selected | |||
expect($('.selectedActions').is(':visible')).toEqual(true); | |||
expect($('.selectedActions .item-delete').is(':visible')).toEqual(true); | |||
expect($('.selectedActions .item-restore').is(':visible')).toEqual(true); | |||
// check | |||
fileList.$el.find('.select-all').click(); | |||
// stays visible | |||
expect($('.selectedActions').is(':visible')).toEqual(true); | |||
expect($('.selectedActions .item-delete').is(':visible')).toEqual(true); | |||
expect($('.selectedActions .item-restore').is(':visible')).toEqual(true); | |||
// uncheck | |||
fileList.$el.find('.select-all').click(); | |||
// becomes hidden now | |||
expect($('.selectedActions').is(':visible')).toEqual(false); | |||
expect($('.selectedActions .item-delete').is(':visible')).toEqual(false); | |||
expect($('.selectedActions .item-restore').is(':visible')).toEqual(false); | |||
}); | |||
it('Deletes selected files when "Delete" clicked', function (done) { | |||
var request; | |||
var promise = fileList._onClickDeleteSelected({ | |||
preventDefault: function () { | |||
} | |||
}); | |||
var files = ["One.txt.d11111", "Three.pdf.d33333", "somedir.d99999"]; | |||
expect(fakeServer.requests.length).toEqual(files.length); | |||
for (var i = 0; i < files.length; i++) { | |||
request = fakeServer.requests[i]; | |||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]); | |||
request.respond(200); | |||
} | |||
return promise.then(function () { | |||
expect(fileList.findFileEl('One.txt.d11111').length).toEqual(0); | |||
expect(fileList.findFileEl('Three.pdf.d33333').length).toEqual(0); | |||
expect(fileList.findFileEl('somedir.d99999').length).toEqual(0); | |||
expect(fileList.findFileEl('Two.jpg.d22222').length).toEqual(1); | |||
}).then(done, done); | |||
}); | |||
it('Deletes all files when all selected when "Delete" clicked', function (done) { | |||
var request; | |||
$('.select-all').click(); | |||
var promise = fileList._onClickDeleteSelected({ | |||
preventDefault: function () { | |||
} | |||
}); | |||
expect(fakeServer.requests.length).toEqual(1); | |||
request = fakeServer.requests[0]; | |||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash'); | |||
request.respond(200); | |||
return promise.then(function () { | |||
expect(fileList.isEmpty).toEqual(true); | |||
}).then(done, done); | |||
}); | |||
}); | |||
describe('Restore', function () { | |||
it('Restores selected files when "Restore" clicked', function (done) { | |||
var request; | |||
var promise = fileList._onClickRestoreSelected({ | |||
preventDefault: function () { | |||
} | |||
}); | |||
var files = ["One.txt.d11111", "Three.pdf.d33333", "somedir.d99999"]; | |||
expect(fakeServer.requests.length).toEqual(files.length); | |||
for (var i = 0; i < files.length; i++) { | |||
request = fakeServer.requests[i]; | |||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]); | |||
expect(request.requestHeaders.Destination).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/restore/' + files[i]); | |||
request.respond(200); | |||
} | |||
return promise.then(function() { | |||
expect(fileList.findFileEl('One.txt.d11111').length).toEqual(0); | |||
expect(fileList.findFileEl('Three.pdf.d33333').length).toEqual(0); | |||
expect(fileList.findFileEl('somedir.d99999').length).toEqual(0); | |||
expect(fileList.findFileEl('Two.jpg.d22222').length).toEqual(1); | |||
}).then(done, done); | |||
}); | |||
it('Restores all files when all selected when "Restore" clicked', function (done) { | |||
var request; | |||
$('.select-all').click(); | |||
var promise = fileList._onClickRestoreSelected({ | |||
preventDefault: function () { | |||
} | |||
}); | |||
var files = ["One.txt.d11111", "Two.jpg.d22222", "Three.pdf.d33333", "somedir.d99999"]; | |||
expect(fakeServer.requests.length).toEqual(files.length); | |||
for (var i = 0; i < files.length; i++) { | |||
request = fakeServer.requests[i]; | |||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]); | |||
expect(request.requestHeaders.Destination).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/restore/' + files[i]); | |||
request.respond(200); | |||
} | |||
return promise.then(function() { | |||
expect(fileList.isEmpty).toEqual(true); | |||
}).then(done, done); | |||
}); | |||
}); | |||
}); | |||
}); |
@@ -10,6 +10,7 @@ module.exports = { | |||
'@babel/preset-env', | |||
{ | |||
useBuiltIns: false, | |||
modules: 'auto', | |||
}, | |||
], | |||
], |
@@ -1,135 +0,0 @@ | |||
/** | |||
* @copyright Bernhard Posselt 2014 | |||
* | |||
* @author Christoph Wurst <christoph@winzerhof-wurst.at> | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
import $ from 'jquery' | |||
let dynamicSlideToggleEnabled = false | |||
const Apps = { | |||
enableDynamicSlideToggle() { | |||
dynamicSlideToggleEnabled = true | |||
}, | |||
} | |||
/** | |||
* Shows the #app-sidebar and add .with-app-sidebar to subsequent siblings | |||
* | |||
* @param {object} [$el] sidebar element to show, defaults to $('#app-sidebar') | |||
*/ | |||
Apps.showAppSidebar = function($el) { | |||
const $appSidebar = $el || $('#app-sidebar') | |||
$appSidebar.removeClass('disappear').show() | |||
$('#app-content').trigger(new $.Event('appresized')) | |||
} | |||
/** | |||
* Shows the #app-sidebar and removes .with-app-sidebar from subsequent | |||
* siblings | |||
* | |||
* @param {object} [$el] sidebar element to hide, defaults to $('#app-sidebar') | |||
*/ | |||
Apps.hideAppSidebar = function($el) { | |||
const $appSidebar = $el || $('#app-sidebar') | |||
$appSidebar.hide().addClass('disappear') | |||
$('#app-content').trigger(new $.Event('appresized')) | |||
} | |||
/** | |||
* Provides a way to slide down a target area through a button and slide it | |||
* up if the user clicks somewhere else. Used for the news app settings and | |||
* add new field. | |||
* | |||
* Usage: | |||
* <button data-apps-slide-toggle=".slide-area">slide</button> | |||
* <div class=".slide-area" class="hidden">I'm sliding up</div> | |||
*/ | |||
export const registerAppsSlideToggle = () => { | |||
let buttons = $('[data-apps-slide-toggle]') | |||
if (buttons.length === 0) { | |||
$('#app-navigation').addClass('without-app-settings') | |||
} | |||
$(document).click(function(event) { | |||
if (dynamicSlideToggleEnabled) { | |||
buttons = $('[data-apps-slide-toggle]') | |||
} | |||
buttons.each(function(index, button) { | |||
const areaSelector = $(button).data('apps-slide-toggle') | |||
const area = $(areaSelector) | |||
/** | |||
* | |||
*/ | |||
function hideArea() { | |||
area.slideUp(OC.menuSpeed * 4, function() { | |||
area.trigger(new $.Event('hide')) | |||
}) | |||
area.removeClass('opened') | |||
$(button).removeClass('opened') | |||
} | |||
/** | |||
* | |||
*/ | |||
function showArea() { | |||
area.slideDown(OC.menuSpeed * 4, function() { | |||
area.trigger(new $.Event('show')) | |||
}) | |||
area.addClass('opened') | |||
$(button).addClass('opened') | |||
const input = $(areaSelector + ' [autofocus]') | |||
if (input.length === 1) { | |||
input.focus() | |||
} | |||
} | |||
// do nothing if the area is animated | |||
if (!area.is(':animated')) { | |||
// button toggles the area | |||
if ($(button).is($(event.target).closest('[data-apps-slide-toggle]'))) { | |||
if (area.is(':visible')) { | |||
hideArea() | |||
} else { | |||
showArea() | |||
} | |||
// all other areas that have not been clicked but are open | |||
// should be slid up | |||
} else { | |||
const closest = $(event.target).closest(areaSelector) | |||
if (area.is(':visible') && closest[0] !== area[0]) { | |||
hideArea() | |||
} | |||
} | |||
} | |||
}) | |||
}) | |||
} | |||
export default Apps |
@@ -30,7 +30,6 @@ import { | |||
processAjaxError, | |||
registerXHRForErrorProcessing, | |||
} from './xhr-error.js' | |||
import Apps from './apps.js' | |||
import { AppConfig, appConfig } from './appconfig.js' | |||
import { appSettings } from './appsettings.js' | |||
import appswebroots from './appswebroots.js' | |||
@@ -45,8 +44,8 @@ import { | |||
import { | |||
build as buildQueryString, | |||
parse as parseQueryString, | |||
} from './query-string.js' | |||
import Config from './config.js' | |||
} from './query-string' | |||
import Config from './config' | |||
import { | |||
coreApps, | |||
menuSpeed, | |||
@@ -58,30 +57,30 @@ import { | |||
PERMISSION_SHARE, | |||
PERMISSION_UPDATE, | |||
TAG_FAVORITE, | |||
} from './constants.js' | |||
import ContactsMenu from './contactsmenu.js' | |||
import { currentUser, getCurrentUser } from './currentuser.js' | |||
import Dialogs from './dialogs.js' | |||
import EventSource from './eventsource.js' | |||
import { get, set } from './get_set.js' | |||
import { getCapabilities } from './capabilities.js' | |||
} from './constants' | |||
import ContactsMenu from './contactsmenu' | |||
import { currentUser, getCurrentUser } from './currentuser' | |||
import Dialogs from './dialogs' | |||
import EventSource from './eventsource' | |||
import { get, set } from './get_set' | |||
import { getCapabilities } from './capabilities' | |||
import { | |||
getHost, | |||
getHostName, | |||
getPort, | |||
getProtocol, | |||
} from './host.js' | |||
} from './host' | |||
import { | |||
getToken as getRequestToken, | |||
} from './requesttoken.js' | |||
} from './requesttoken' | |||
import { | |||
hideMenus, | |||
registerMenu, | |||
showMenu, | |||
unregisterMenu, | |||
} from './menu.js' | |||
import { isUserAdmin } from './admin.js' | |||
import L10N from './l10n.js' | |||
} from './menu' | |||
import { isUserAdmin } from './admin' | |||
import L10N from './l10n' | |||
import { | |||
getCanonicalLocale, | |||
getLanguage, | |||
@@ -141,7 +140,6 @@ export default { | |||
addScript, | |||
addStyle, | |||
Apps, | |||
AppConfig, | |||
appConfig, | |||
appSettings, |
@@ -165,6 +165,8 @@ export default { | |||
}, | |||
_onPopState(e) { | |||
debugger | |||
if (this._cancelPop) { | |||
this._cancelPop = false | |||
return |
@@ -35,11 +35,9 @@ import OC from './OC/index.js' | |||
import './globals.js' | |||
import './jquery/index.js' | |||
import { initCore } from './init.js' | |||
import { registerAppsSlideToggle } from './OC/apps.js' | |||
window.addEventListener('DOMContentLoaded', function() { | |||
initCore() | |||
registerAppsSlideToggle() | |||
// fallback to hashchange when no history support | |||
if (window.history.pushState) { |
@@ -1,7 +1,7 @@ | |||
/** | |||
* @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author Roeland Jago Douma <roeland@famdouma.nl> | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
@@ -19,9 +19,13 @@ | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
declare module '*.svg' { | |||
const content: any | |||
export default content | |||
} | |||
import './app.js' | |||
import './filelist.js' | |||
import './trash.scss' | |||
declare module '*.vue' { | |||
import Vue from 'vue' | |||
export default Vue | |||
} | |||
window.OCA.Trashbin = OCA.Trashbin |
@@ -0,0 +1,34 @@ | |||
/** | |||
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @author John Molakvoæ <skjnldsv@protonmail.com> | |||
* | |||
* @license AGPL-3.0-or-later | |||
* | |||
* This program is free software: you can redistribute it and/or modify | |||
* it under the terms of the GNU Affero General Public License as | |||
* published by the Free Software Foundation, either version 3 of the | |||
* License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
* GNU Affero General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Affero General Public License | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import { mount } from 'cypress/vue2' | |||
type MountParams = Parameters<typeof mount>; | |||
type OptionsParam = MountParams[1]; | |||
declare global { | |||
namespace Cypress { | |||
interface Chainable { | |||
mount: typeof mount; | |||
} | |||
} | |||
} |
@@ -19,21 +19,9 @@ | |||
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
* | |||
*/ | |||
/* eslint-disable */ | |||
import { mount } from 'cypress/vue2' | |||
// Augment the Cypress namespace to include type definitions for | |||
// your custom command. | |||
// Alternatively, can be defined in cypress/support/component.d.ts | |||
// with a <reference path="./component" /> at the top of your spec. | |||
declare global { | |||
// eslint-disable-next-line @typescript-eslint/no-namespace | |||
namespace Cypress { | |||
interface Chainable { | |||
mount: typeof mount | |||
} | |||
} | |||
} | |||
// Example use: | |||
// cy.mount(MyComponent) | |||
Cypress.Commands.add('mount', (component, optionsOrProps) => { |
@@ -44,7 +44,7 @@ | |||
"@nextcloud/capabilities": "^1.0.4", | |||
"@nextcloud/dialogs": "^4.0.0-beta.2", | |||
"@nextcloud/event-bus": "^3.0.2", | |||
"@nextcloud/files": "^3.0.0-beta.5", | |||
"@nextcloud/files": "^3.0.0-beta.7", | |||
"@nextcloud/initial-state": "^2.0.0", | |||
"@nextcloud/l10n": "^2.1.0", | |||
"@nextcloud/logger": "^2.5.0", | |||
@@ -99,15 +99,17 @@ | |||
"vue": "^2.7.14", | |||
"vue-click-outside": "^1.1.0", | |||
"vue-cropperjs": "^4.2.0", | |||
"vue-fragment": "^1.6.0", | |||
"vue-infinite-loading": "^2.4.5", | |||
"vue-localstorage": "^0.6.2", | |||
"vue-material-design-icons": "^5.0.0", | |||
"vue-multiselect": "^2.1.6", | |||
"vue-router": "^3.6.5", | |||
"vue-virtual-scroll-list": "github:skjnldsv/vue-virtual-scroll-list#feat/table", | |||
"vuedraggable": "^2.24.3", | |||
"vuex": "^3.6.2", | |||
"vuex-router-sync": "^5.0.0", | |||
"webdav": "^4.11.0" | |||
"webdav": "^5.0.0-r1" | |||
}, | |||
"devDependencies": { | |||
"@babel/node": "^7.20.7", | |||
@@ -160,6 +162,7 @@ | |||
"sass-loader": "^13.2.0", | |||
"sinon": "<= 5.0.7", | |||
"style-loader": "^3.3.1", | |||
"ts-loader": "^9.4.2", | |||
"ts-node": "^10.9.1", | |||
"tslib": "^2.4.1", | |||
"typescript": "^4.9.3", |
@@ -1,8 +1,8 @@ | |||
{ | |||
"extends": "@vue/tsconfig/tsconfig.json", | |||
"include": ["./apps/**/*.ts", "./core/**/*.ts"], | |||
"include": ["./apps/**/*.ts", "./core/**/*.ts", "./*.d.ts"], | |||
"compilerOptions": { | |||
"types": ["node"], | |||
"types": ["cypress", "node", "vue"], | |||
"outDir": "./dist/", | |||
"target": "ESNext", | |||
"module": "esnext", |
@@ -171,6 +171,7 @@ module.exports = { | |||
alias: { | |||
// make sure to use the handlebar runtime when importing | |||
handlebars: 'handlebars/runtime', | |||
vue$: path.resolve('./node_modules/vue'), | |||
}, | |||
extensions: ['*', '.ts', '.js', '.vue'], | |||
symlinks: true, |
@@ -63,7 +63,7 @@ module.exports = { | |||
'personal-settings': path.join(__dirname, 'apps/files_sharing/src', 'personal-settings.js'), | |||
}, | |||
files_trashbin: { | |||
files_trashbin: path.join(__dirname, 'apps/files_trashbin/src', 'files_trashbin.js'), | |||
main: path.join(__dirname, 'apps/files_trashbin/src', 'main.ts'), | |||
}, | |||
files_versions: { | |||
files_versions: path.join(__dirname, 'apps/files_versions/src', 'files_versions_tab.js'), |