aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_versions/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files_versions/src')
-rw-r--r--apps/files_versions/src/components/Version.vue492
-rw-r--r--apps/files_versions/src/components/VersionLabelDialog.vue123
-rw-r--r--apps/files_versions/src/components/VirtualScrolling.vue346
-rw-r--r--apps/files_versions/src/css/versions.css103
-rw-r--r--apps/files_versions/src/files_versions_tab.js30
-rw-r--r--apps/files_versions/src/utils/davClient.js47
-rw-r--r--apps/files_versions/src/utils/davRequest.js24
-rw-r--r--apps/files_versions/src/utils/logger.js20
-rw-r--r--apps/files_versions/src/utils/versions.js139
-rw-r--r--apps/files_versions/src/utils/versions.ts133
-rw-r--r--apps/files_versions/src/views/VersionTab.vue218
11 files changed, 1209 insertions, 466 deletions
diff --git a/apps/files_versions/src/components/Version.vue b/apps/files_versions/src/components/Version.vue
index 6a9bac977a8..dc36e4134f9 100644
--- a/apps/files_versions/src/components/Version.vue
+++ b/apps/files_versions/src/components/Version.vue
@@ -1,162 +1,177 @@
<!--
- - @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div>
- <NcListItem class="version"
- :title="versionLabel"
- :href="downloadURL"
- :force-display-actions="true"
- data-files-versions-version>
- <template #icon>
- <img lazy="true"
- :src="previewURL"
- alt=""
- height="256"
- width="256"
- class="version__image">
- </template>
- <template #subtitle>
- <div class="version__info">
- <span v-tooltip="formattedDate">{{ version.mtime | humanDateFromNow }}</span>
- <!-- Separate dot to improve alignement -->
- <span class="version__info__size">•</span>
- <span class="version__info__size">{{ version.size | humanReadableSize }}</span>
+ <NcListItem class="version"
+ :force-display-actions="true"
+ :actions-aria-label="t('files_versions', 'Actions for version from {versionHumanExplicitDate}', { versionHumanExplicitDate })"
+ :data-files-versions-version="version.fileVersion"
+ @click="click">
+ <!-- Icon -->
+ <template #icon>
+ <div v-if="!(loadPreview || previewLoaded)" class="version__image" />
+ <img v-else-if="version.previewUrl && !previewErrored"
+ :src="version.previewUrl"
+ alt=""
+ decoding="async"
+ fetchpriority="low"
+ loading="lazy"
+ class="version__image"
+ @load="previewLoaded = true"
+ @error="previewErrored = true">
+ <div v-else
+ class="version__image">
+ <ImageOffOutline :size="20" />
+ </div>
+ </template>
+
+ <!-- author -->
+ <template #name>
+ <div class="version__info">
+ <div v-if="versionLabel"
+ class="version__info__label"
+ data-cy-files-version-label
+ :title="versionLabel">
+ {{ versionLabel }}
</div>
- </template>
- <template #actions>
- <NcActionButton v-if="capabilities.files.version_labeling === true"
- :close-after-click="true"
- @click="openVersionLabelModal">
- <template #icon>
- <Pencil :size="22" />
- </template>
- {{ version.label === '' ? t('files_versions', 'Name this version') : t('files_versions', 'Edit version name') }}
- </NcActionButton>
- <NcActionButton v-if="!isCurrent"
- :close-after-click="true"
- @click="restoreVersion">
- <template #icon>
- <BackupRestore :size="22" />
- </template>
- {{ t('files_versions', 'Restore version') }}
- </NcActionButton>
- <NcActionLink :href="downloadURL"
- :close-after-click="true"
- :download="downloadURL">
- <template #icon>
- <Download :size="22" />
- </template>
- {{ t('files_versions', 'Download version') }}
- </NcActionLink>
- <NcActionButton v-if="!isCurrent && capabilities.files.version_deletion === true"
- :close-after-click="true"
- @click="deleteVersion">
- <template #icon>
- <Delete :size="22" />
- </template>
- {{ t('files_versions', 'Delete version') }}
- </NcActionButton>
- </template>
- </NcListItem>
- <NcModal v-if="showVersionLabelForm"
- :title="t('files_versions', 'Name this version')"
- @close="showVersionLabelForm = false">
- <form class="version-label-modal"
- @submit.prevent="setVersionLabel(formVersionLabelValue)">
- <label>
- <div class="version-label-modal__title">{{ t('photos', 'Version name') }}</div>
- <NcTextField ref="labelInput"
- :value.sync="formVersionLabelValue"
- :placeholder="t('photos', 'Version name')"
- :label-outside="true" />
- </label>
-
- <div class="version-label-modal__info">
- {{ t('photos', 'Named versions are persisted, and excluded from automatic cleanups when your storage quota is full.') }}
+ <div v-if="versionAuthor"
+ class="version__info"
+ data-cy-files-version-author-name>
+ <span v-if="versionLabel">•</span>
+ <NcAvatar class="avatar"
+ :user="version.author"
+ :size="20"
+ disable-menu
+ disable-tooltip
+ :show-user-status="false" />
+ <div class="version__info__author_name"
+ :title="versionAuthor">
+ {{ versionAuthor }}
+ </div>
</div>
+ </div>
+ </template>
- <div class="version-label-modal__actions">
- <NcButton :disabled="formVersionLabelValue.trim().length === 0" @click="setVersionLabel('')">
- {{ t('files_versions', 'Remove version name') }}
- </NcButton>
- <NcButton type="primary" native-type="submit">
- <template #icon>
- <Check />
- </template>
- {{ t('files_versions', 'Save version name') }}
- </NcButton>
- </div>
- </form>
- </NcModal>
- </div>
+ <!-- Version file size as subline -->
+ <template #subname>
+ <div class="version__info version__info__subline">
+ <NcDateTime class="version__info__date"
+ relative-time="short"
+ :timestamp="version.mtime" />
+ <!-- Separate dot to improve alignment -->
+ <span>•</span>
+ <span>{{ humanReadableSize }}</span>
+ </div>
+ </template>
+
+ <!-- Actions -->
+ <template #actions>
+ <NcActionButton v-if="enableLabeling && hasUpdatePermissions"
+ data-cy-files-versions-version-action="label"
+ :close-after-click="true"
+ @click="labelUpdate">
+ <template #icon>
+ <Pencil :size="22" />
+ </template>
+ {{ version.label === '' ? t('files_versions', 'Name this version') : t('files_versions', 'Edit version name') }}
+ </NcActionButton>
+ <NcActionButton v-if="!isCurrent && canView && canCompare"
+ data-cy-files-versions-version-action="compare"
+ :close-after-click="true"
+ @click="compareVersion">
+ <template #icon>
+ <FileCompare :size="22" />
+ </template>
+ {{ t('files_versions', 'Compare to current version') }}
+ </NcActionButton>
+ <NcActionButton v-if="!isCurrent && hasUpdatePermissions"
+ data-cy-files-versions-version-action="restore"
+ :close-after-click="true"
+ @click="restoreVersion">
+ <template #icon>
+ <BackupRestore :size="22" />
+ </template>
+ {{ t('files_versions', 'Restore version') }}
+ </NcActionButton>
+ <NcActionLink v-if="isDownloadable"
+ data-cy-files-versions-version-action="download"
+ :href="downloadURL"
+ :close-after-click="true"
+ :download="downloadURL">
+ <template #icon>
+ <Download :size="22" />
+ </template>
+ {{ t('files_versions', 'Download version') }}
+ </NcActionLink>
+ <NcActionButton v-if="!isCurrent && enableDeletion && hasDeletePermissions"
+ data-cy-files-versions-version-action="delete"
+ :close-after-click="true"
+ @click="deleteVersion">
+ <template #icon>
+ <Delete :size="22" />
+ </template>
+ {{ t('files_versions', 'Delete version') }}
+ </NcActionButton>
+ </template>
+ </NcListItem>
</template>
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { Version } from '../utils/versions'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Permission, formatFileSize } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { joinPaths } from '@nextcloud/paths'
+import { getRootUrl, generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
+import axios from '@nextcloud/axios'
+import moment from '@nextcloud/moment'
+import logger from '../utils/logger'
-<script>
import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
+import Delete from 'vue-material-design-icons/Delete.vue'
import Download from 'vue-material-design-icons/Download.vue'
-import Pencil from 'vue-material-design-icons/Pencil.vue'
-import Check from 'vue-material-design-icons/Check.vue'
-import Delete from 'vue-material-design-icons/Delete'
-import { NcActionButton, NcActionLink, NcListItem, NcModal, NcButton, NcTextField, Tooltip } from '@nextcloud/vue'
-import moment from '@nextcloud/moment'
-import { translate } from '@nextcloud/l10n'
-import { joinPaths } from '@nextcloud/paths'
-import { generateUrl } from '@nextcloud/router'
-import { loadState } from '@nextcloud/initial-state'
+import FileCompare from 'vue-material-design-icons/FileCompare.vue'
+import ImageOffOutline from 'vue-material-design-icons/ImageOffOutline.vue'
+import Pencil from 'vue-material-design-icons/PencilOutline.vue'
-export default {
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import Tooltip from '@nextcloud/vue/directives/Tooltip'
+
+const hasPermission = (permissions: number, permission: number): boolean => (permissions & permission) !== 0
+
+export default defineComponent({
name: 'Version',
+
components: {
NcActionLink,
NcActionButton,
+ NcAvatar,
+ NcDateTime,
NcListItem,
- NcModal,
- NcButton,
- NcTextField,
BackupRestore,
Download,
+ FileCompare,
Pencil,
- Check,
Delete,
+ ImageOffOutline,
},
+
directives: {
tooltip: Tooltip,
},
- filters: {
- /**
- * @param {number} bytes
- * @return {string}
- */
- humanReadableSize(bytes) {
- return OC.Util.humanFileSize(bytes)
- },
- /**
- * @param {number} timestamp
- * @return {string}
- */
- humanDateFromNow(timestamp) {
- return moment(timestamp).fromNow()
- },
- },
+
props: {
- /** @type {Vue.PropOptions<import('../utils/versions.js').Version>} */
version: {
- type: Object,
+ type: Object as PropType<Version>,
required: true,
},
fileInfo: {
@@ -171,86 +186,158 @@ export default {
type: Boolean,
default: false,
},
+ loadPreview: {
+ type: Boolean,
+ default: false,
+ },
+ canView: {
+ type: Boolean,
+ default: false,
+ },
+ canCompare: {
+ type: Boolean,
+ default: false,
+ },
},
+
+ emits: ['click', 'compare', 'restore', 'delete', 'label-update-request'],
+
data() {
return {
- showVersionLabelForm: false,
- formVersionLabelValue: this.version.label,
+ previewLoaded: false,
+ previewErrored: false,
capabilities: loadState('core', 'capabilities', { files: { version_labeling: false, version_deletion: false } }),
+ versionAuthor: '' as string | null,
}
},
+
computed: {
- /**
- * @return {string}
- */
- versionLabel() {
+ humanReadableSize() {
+ return formatFileSize(this.version.size)
+ },
+
+ versionLabel(): string {
+ const label = this.version.label ?? ''
+
if (this.isCurrent) {
- if (this.version.label === undefined || this.version.label === '') {
- return translate('files_versions', 'Current version')
+ if (label === '') {
+ return t('files_versions', 'Current version')
} else {
- return `${this.version.label} (${translate('files_versions', 'Current version')})`
+ return `${label} (${t('files_versions', 'Current version')})`
}
}
- if (this.isFirstVersion && this.version.label === '') {
- return translate('files_versions', 'Initial version')
+ if (this.isFirstVersion && label === '') {
+ return t('files_versions', 'Initial version')
}
- return this.version.label
+ return label
+ },
+
+ versionHumanExplicitDate(): string {
+ return moment(this.version.mtime).format('LLLL')
},
- /**
- * @return {string}
- */
- downloadURL() {
+ downloadURL(): string {
if (this.isCurrent) {
- return joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name)
+ return getRootUrl() + joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name)
} else {
- return this.version.url
+ return getRootUrl() + this.version.url
}
},
- /**
- * @return {string}
- */
- previewURL() {
- if (this.isCurrent) {
- return generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', {
- fileId: this.fileInfo.id,
- fileEtag: this.fileInfo.etag,
- })
- } else {
- return this.version.preview
+ enableLabeling(): boolean {
+ return this.capabilities.files.version_labeling === true
+ },
+
+ enableDeletion(): boolean {
+ return this.capabilities.files.version_deletion === true
+ },
+
+ hasDeletePermissions(): boolean {
+ return hasPermission(this.fileInfo.permissions, Permission.DELETE)
+ },
+
+ hasUpdatePermissions(): boolean {
+ return hasPermission(this.fileInfo.permissions, Permission.UPDATE)
+ },
+
+ isDownloadable(): boolean {
+ if ((this.fileInfo.permissions & Permission.READ) === 0) {
+ return false
}
+
+ // If the mount type is a share, ensure it got download permissions.
+ if (this.fileInfo.mountType === 'shared') {
+ const downloadAttribute = this.fileInfo.shareAttributes
+ .find((attribute) => attribute.scope === 'permissions' && attribute.key === 'download') || {}
+ // If the download attribute is set to false, the file is not downloadable
+ if (downloadAttribute?.value === false) {
+ return false
+ }
+ }
+
+ return true
},
},
+
+ created() {
+ this.fetchDisplayName()
+ },
+
methods: {
- openVersionLabelModal() {
- this.showVersionLabelForm = true
- this.$nextTick(() => {
- this.$refs.labelInput.$el.getElementsByTagName('input')[0].focus()
- })
+ labelUpdate() {
+ this.$emit('label-update-request')
},
restoreVersion() {
this.$emit('restore', this.version)
},
- setVersionLabel(label) {
- this.formVersionLabelValue = label
- this.showVersionLabelForm = false
- this.$emit('label-update', this.version, label)
+ async deleteVersion() {
+ // Let @nc-vue properly remove the popover before we delete the version.
+ // This prevents @nc-vue from throwing a error.
+ await this.$nextTick()
+ await this.$nextTick()
+ this.$emit('delete', this.version)
},
- deleteVersion() {
- this.$emit('delete', this.version)
+ async fetchDisplayName() {
+ this.versionAuthor = null
+ if (!this.version.author) {
+ return
+ }
+
+ if (this.version.author === getCurrentUser()?.uid) {
+ this.versionAuthor = t('files_versions', 'You')
+ } else {
+ try {
+ const { data } = await axios.post(generateUrl('/displaynames'), { users: [this.version.author] })
+ this.versionAuthor = data.users[this.version.author]
+ } catch (error) {
+ logger.warn('Could not load user display name', { error })
+ }
+ }
},
- formattedDate() {
- return moment(this.version.mtime)
+ click() {
+ if (!this.canView) {
+ window.location.href = this.downloadURL
+ return
+ }
+ this.$emit('click', { version: this.version })
},
+
+ compareVersion() {
+ if (!this.canView) {
+ throw new Error('Cannot compare version of this file')
+ }
+ this.$emit('compare', { version: this.version })
+ },
+
+ t,
},
-}
+})
</script>
<style scoped lang="scss">
@@ -263,9 +350,31 @@ export default {
flex-direction: row;
align-items: center;
gap: 0.5rem;
+ color: var(--color-main-text);
+ font-weight: 500;
+ overflow: hidden;
+
+ &__label {
+ font-weight: 700;
+ // Fix overflow on narrow screens
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 110px;
+ }
+
+ &__author_name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
- &__size {
- color: var(--color-text-lighter);
+ &__date {
+ // Fix overflow on narrow screens
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__subline {
+ color: var(--color-text-maxcontrast)
}
}
@@ -274,30 +383,11 @@ export default {
height: 3rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
- }
-}
-
-.version-label-modal {
- display: flex;
- justify-content: space-between;
- flex-direction: column;
- height: 250px;
- padding: 16px;
-
- &__title {
- margin-bottom: 12px;
- font-weight: 600;
- }
-
- &__info {
- margin-top: 12px;
- color: var(--color-text-maxcontrast);
- }
- &__actions {
+ // Useful to display no preview icon.
display: flex;
- justify-content: space-between;
- margin-top: 64px;
+ justify-content: center;
+ color: var(--color-text-light);
}
}
</style>
diff --git a/apps/files_versions/src/components/VersionLabelDialog.vue b/apps/files_versions/src/components/VersionLabelDialog.vue
new file mode 100644
index 00000000000..760780cae61
--- /dev/null
+++ b/apps/files_versions/src/components/VersionLabelDialog.vue
@@ -0,0 +1,123 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog :buttons="dialogButtons"
+ content-classes="version-label-modal"
+ is-form
+ :open="open"
+ size="normal"
+ :name="t('files_versions', 'Name this version')"
+ @update:open="$emit('update:open', $event)"
+ @submit="setVersionLabel(editedVersionLabel)">
+ <NcTextField ref="labelInput"
+ class="version-label-modal__input"
+ :label="t('files_versions', 'Version name')"
+ :placeholder="t('files_versions', 'Version name')"
+ :value.sync="editedVersionLabel" />
+
+ <p class="version-label-modal__info">
+ {{ t('files_versions', 'Named versions are persisted, and excluded from automatic cleanups when your storage quota is full.') }}
+ </p>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import { t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+import svgCheck from '@mdi/svg/svg/check.svg?raw'
+
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+type Focusable = Vue & { focus: () => void }
+
+export default defineComponent({
+ name: 'VersionLabelDialog',
+ components: {
+ NcDialog,
+ NcTextField,
+ },
+ props: {
+ open: {
+ type: Boolean,
+ default: false,
+ },
+ versionLabel: {
+ type: String,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ editedVersionLabel: '',
+ }
+ },
+ computed: {
+ dialogButtons() {
+ const buttons: unknown[] = []
+ if (this.versionLabel.trim() === '') {
+ // If there is no label just offer a cancel action that just closes the dialog
+ buttons.push({
+ label: t('files_versions', 'Cancel'),
+ })
+ } else {
+ // If there is already a label set, offer to remove the version label
+ buttons.push({
+ label: t('files_versions', 'Remove version name'),
+ type: 'error',
+ nativeType: 'reset',
+ callback: () => { this.setVersionLabel('') },
+ })
+ }
+ return [
+ ...buttons,
+ {
+ label: t('files_versions', 'Save version name'),
+ type: 'primary',
+ nativeType: 'submit',
+ icon: svgCheck,
+ },
+ ]
+ },
+ },
+ watch: {
+ versionLabel: {
+ immediate: true,
+ handler(label) {
+ this.editedVersionLabel = label ?? ''
+ },
+ },
+ open: {
+ immediate: true,
+ handler(open) {
+ if (open) {
+ this.$nextTick(() => (this.$refs.labelInput as Focusable).focus())
+ }
+ this.editedVersionLabel = this.versionLabel
+ },
+ },
+ },
+ methods: {
+ setVersionLabel(label: string) {
+ this.$emit('label-update', label)
+ },
+
+ t,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.version-label-modal {
+ &__info {
+ color: var(--color-text-maxcontrast);
+ margin-block: calc(3 * var(--default-grid-baseline));
+ }
+
+ &__input {
+ margin-block-start: calc(2 * var(--default-grid-baseline));
+ }
+}
+</style>
diff --git a/apps/files_versions/src/components/VirtualScrolling.vue b/apps/files_versions/src/components/VirtualScrolling.vue
new file mode 100644
index 00000000000..5a502036839
--- /dev/null
+++ b/apps/files_versions/src/components/VirtualScrolling.vue
@@ -0,0 +1,346 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div v-if="!useWindow && containerElement === null" ref="container" class="vs-container">
+ <div ref="rowsContainer"
+ class="vs-rows-container"
+ :style="rowsContainerStyle">
+ <slot :visible-sections="visibleSections" />
+ <slot name="loader" />
+ </div>
+ </div>
+ <div v-else
+ ref="rowsContainer"
+ class="vs-rows-container"
+ :style="rowsContainerStyle">
+ <slot :visible-sections="visibleSections" />
+ <slot name="loader" />
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+
+import logger from '../utils/logger.js'
+
+interface RowItem {
+ id: string // Unique id for the item.
+ key?: string // Unique key for the item.
+}
+
+interface Row {
+ key: string // Unique key for the row.
+ height: number // The height of the row.
+ sectionKey: string // Unique key for the row.
+ items: RowItem[] // List of items in the row.
+}
+
+interface VisibleRow extends Row {
+ distance: number // The distance from the visible viewport
+}
+
+interface Section {
+ key: string, // Unique key for the section.
+ rows: Row[], // The height of the row.
+ height: number, // Height of the section, excluding the header.
+}
+
+interface VisibleSection extends Section {
+ rows: VisibleRow[], // The height of the row.
+}
+
+export default defineComponent({
+ name: 'VirtualScrolling',
+
+ props: {
+ sections: {
+ type: Array as PropType<Section[]>,
+ required: true,
+ },
+
+ containerElement: {
+ type: HTMLElement,
+ default: null,
+ },
+
+ useWindow: {
+ type: Boolean,
+ default: false,
+ },
+
+ headerHeight: {
+ type: Number,
+ default: 75,
+ },
+ renderDistance: {
+ type: Number,
+ default: 0.5,
+ },
+ bottomBufferRatio: {
+ type: Number,
+ default: 2,
+ },
+ scrollToKey: {
+ type: String,
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ scrollPosition: 0,
+ containerHeight: 0,
+ rowsContainerHeight: 0,
+ resizeObserver: null as ResizeObserver|null,
+ }
+ },
+
+ computed: {
+ visibleSections(): VisibleSection[] {
+ logger.debug('[VirtualScrolling] Computing visible section', { sections: this.sections })
+
+ // Optimization: get those computed properties once to not go through vue's internal every time we need them.
+ const containerHeight = this.containerHeight
+ const containerTop = this.scrollPosition
+ const containerBottom = containerTop + containerHeight
+
+ let currentRowTop = 0
+ let currentRowBottom = 0
+
+ // Compute whether a row should be included in the DOM (shouldRender)
+ // And how visible the row is.
+ const visibleSections = this.sections
+ .map(section => {
+ currentRowBottom += this.headerHeight
+
+ return {
+ ...section,
+ rows: section.rows.reduce((visibleRows, row) => {
+ currentRowTop = currentRowBottom
+ currentRowBottom += row.height
+
+ let distance = 0
+
+ if (currentRowBottom < containerTop) {
+ distance = (containerTop - currentRowBottom) / containerHeight
+ } else if (currentRowTop > containerBottom) {
+ distance = (currentRowTop - containerBottom) / containerHeight
+ }
+
+ if (distance > this.renderDistance) {
+ return visibleRows
+ }
+
+ return [
+ ...visibleRows,
+ {
+ ...row,
+ distance,
+ },
+ ]
+ }, [] as VisibleRow[]),
+ }
+ })
+ .filter(section => section.rows.length > 0)
+
+ // To allow vue to recycle the DOM elements instead of adding and deleting new ones,
+ // we assign a random key to each items. When a item removed, we recycle its key for new items,
+ // so vue can replace the content of removed DOM elements with the content of new items, but keep the other DOM elements untouched.
+ const visibleItems = visibleSections
+ .flatMap(({ rows }) => rows)
+ .flatMap(({ items }) => items)
+
+ const rowIdToKeyMap = this._rowIdToKeyMap as {[key: string]: string}
+
+ visibleItems.forEach(item => (item.key = rowIdToKeyMap[item.id]))
+
+ const usedTokens = visibleItems
+ .map(({ key }) => key)
+ .filter(key => key !== undefined)
+
+ const unusedTokens = Object.values(rowIdToKeyMap).filter(key => !usedTokens.includes(key))
+
+ visibleItems
+ .filter(({ key }) => key === undefined)
+ .forEach(item => (item.key = unusedTokens.pop() ?? Math.random().toString(36).substr(2)))
+
+ // this._rowIdToKeyMap is created in the beforeCreate hook, so value changes are not tracked.
+ // Therefore, we wont trigger the computation of visibleSections again if we alter the value of this._rowIdToKeyMap.
+ // eslint-disable-next-line vue/no-side-effects-in-computed-properties
+ this._rowIdToKeyMap = visibleItems.reduce((finalMapping, { id, key }) => ({ ...finalMapping, [`${id}`]: key }), {})
+
+ return visibleSections
+ },
+
+ /**
+ * Total height of all the rows + some room for the loader.
+ */
+ totalHeight(): number {
+ const loaderHeight = 0
+
+ return this.sections
+ .map(section => this.headerHeight + section.height)
+ .reduce((totalHeight, sectionHeight) => totalHeight + sectionHeight, 0) + loaderHeight
+ },
+
+ paddingTop(): number {
+ if (this.visibleSections.length === 0) {
+ return 0
+ }
+
+ let paddingTop = 0
+
+ for (const section of this.sections) {
+ if (section.key !== this.visibleSections[0].rows[0].sectionKey) {
+ paddingTop += this.headerHeight + section.height
+ continue
+ }
+
+ for (const row of section.rows) {
+ if (row.key === this.visibleSections[0].rows[0].key) {
+ return paddingTop
+ }
+
+ paddingTop += row.height
+ }
+
+ paddingTop += this.headerHeight
+ }
+
+ return paddingTop
+ },
+
+ /**
+ * padding-top is used to replace not included item in the container.
+ */
+ rowsContainerStyle(): { height: string; paddingTop: string } {
+ return {
+ height: `${this.totalHeight}px`,
+ paddingTop: `${this.paddingTop}px`,
+ }
+ },
+
+ /**
+ * Whether the user is near the bottom.
+ * If true, then the need-content event will be emitted.
+ */
+ isNearBottom(): boolean {
+ const buffer = this.containerHeight * this.bottomBufferRatio
+ return this.scrollPosition + this.containerHeight >= this.totalHeight - buffer
+ },
+
+ container() {
+ logger.debug('[VirtualScrolling] Computing container')
+ if (this.containerElement !== null) {
+ return this.containerElement
+ } else if (this.useWindow) {
+ return window
+ } else {
+ return this.$refs.container as Element
+ }
+ },
+ },
+
+ watch: {
+ isNearBottom(value) {
+ logger.debug('[VirtualScrolling] isNearBottom changed', { value })
+ if (value) {
+ this.$emit('need-content')
+ }
+ },
+
+ visibleSections() {
+ // Re-emit need-content when rows is updated and isNearBottom is still true.
+ // If the height of added rows is under `bottomBufferRatio`, `isNearBottom` will still be true so we need more content.
+ if (this.isNearBottom) {
+ this.$emit('need-content')
+ }
+ },
+
+ scrollToKey(key) {
+ let currentRowTopDistanceFromTop = 0
+
+ for (const section of this.sections) {
+ if (section.key !== key) {
+ currentRowTopDistanceFromTop += this.headerHeight + section.height
+ continue
+ }
+
+ break
+ }
+
+ logger.debug('[VirtualScrolling] Scrolling to', { currentRowTopDistanceFromTop })
+ this.container.scrollTo({ top: currentRowTopDistanceFromTop, behavior: 'smooth' })
+ },
+ },
+
+ beforeCreate() {
+ this._rowIdToKeyMap = {}
+ },
+
+ mounted() {
+ this.resizeObserver = new ResizeObserver(entries => {
+ for (const entry of entries) {
+ const cr = entry.contentRect
+ if (entry.target === this.container) {
+ this.containerHeight = cr.height
+ }
+ if (entry.target.classList.contains('vs-rows-container')) {
+ this.rowsContainerHeight = cr.height
+ }
+ }
+ })
+
+ if (this.useWindow) {
+ window.addEventListener('resize', this.updateContainerSize, { passive: true })
+ this.containerHeight = window.innerHeight
+ } else {
+ this.resizeObserver.observe(this.container as HTMLElement|Element)
+ }
+
+ this.resizeObserver.observe(this.$refs.rowsContainer as Element)
+ this.container.addEventListener('scroll', this.updateScrollPosition, { passive: true })
+ },
+
+ beforeDestroy() {
+ if (this.useWindow) {
+ window.removeEventListener('resize', this.updateContainerSize)
+ }
+
+ this.resizeObserver?.disconnect()
+ this.container.removeEventListener('scroll', this.updateScrollPosition)
+ },
+
+ methods: {
+ updateScrollPosition() {
+ this._onScrollHandle ??= requestAnimationFrame(() => {
+ this._onScrollHandle = null
+ if (this.useWindow) {
+ this.scrollPosition = (this.container as Window).scrollY
+ } else {
+ this.scrollPosition = (this.container as HTMLElement|Element).scrollTop
+ }
+ })
+ },
+
+ updateContainerSize() {
+ this.containerHeight = window.innerHeight
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.vs-container {
+ overflow-y: scroll;
+ height: 100%;
+}
+
+.vs-rows-container {
+ box-sizing: border-box;
+ will-change: scroll-position, padding;
+ contain: layout paint style;
+}
+</style>
diff --git a/apps/files_versions/src/css/versions.css b/apps/files_versions/src/css/versions.css
new file mode 100644
index 00000000000..1637394ef48
--- /dev/null
+++ b/apps/files_versions/src/css/versions.css
@@ -0,0 +1,103 @@
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+.versionsTabView .clear-float {
+ clear: both;
+}
+
+.versionsTabView li {
+ width: 100%;
+ cursor: default;
+ height: 56px;
+ float: left;
+ border-bottom: 0;
+}
+
+.versionsTabView li:last-child {
+ border-bottom: none;
+}
+
+.versionsTabView a,
+.versionsTabView div > span {
+ vertical-align: middle;
+ opacity: .5;
+}
+
+.versionsTabView li a{
+ padding: 19px 10px 7px;
+}
+
+.versionsTabView a:hover,
+.versionsTabView a:focus {
+ opacity: 1;
+}
+
+.versionsTabView .preview-container {
+ display: inline-block;
+ vertical-align: top;
+}
+
+.versionsTabView img {
+ cursor: pointer;
+ padding-inline-end: 4px;
+}
+
+.versionsTabView img.preview {
+ position: relative;
+ top: 6px;
+ inset-inline-start: 10px;
+ border: 1px solid var(--color-border-dark);
+ cursor: default;
+ padding-inline-end: 0;
+}
+
+.versionsTabView .version-container {
+ display: inline-block;
+}
+
+.versionsTabView .versiondate {
+ min-width: 100px;
+ vertical-align: super;
+}
+
+.versionsTabView .version-details {
+ text-align: start;
+}
+
+.versionsTabView .version-details > span {
+ padding: 0 10px;
+}
+
+.versionsTabView .revertVersion {
+ cursor: pointer;
+ float: right;
+ margin-inline-end: 0;
+}
+
+.versionsTabView li.active .downloadVersion {
+ opacity: 1;
+}
+
+.versionsTabView li.active .version-details .size {
+ color: var(--color-main-text);
+ opacity: 1;
+}
+
+.versionsTabView li.active {
+ background-color: var(--color-primary-light);
+ border-radius: 16px;
+}
+
+.versionsTabView li.active a.revertVersion {
+ opacity: 1;
+}
+
+.version-container {
+ padding-inline-start: 5px;
+}
+
+.version-details {
+ margin-top: -7px;
+}
diff --git a/apps/files_versions/src/files_versions_tab.js b/apps/files_versions/src/files_versions_tab.js
index e67199436fa..12f36bad24a 100644
--- a/apps/files_versions/src/files_versions_tab.js
+++ b/apps/files_versions/src/files_versions_tab.js
@@ -1,34 +1,20 @@
/**
- * @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import VersionTab from './views/VersionTab.vue'
-import VTooltip from 'v-tooltip'
-// eslint-disable-next-line node/no-missing-import, import/no-unresolved
+import VTooltipPlugin from 'v-tooltip'
+// eslint-disable-next-line n/no-missing-import, import/no-unresolved
import BackupRestore from '@mdi/svg/svg/backup-restore.svg?raw'
Vue.prototype.t = t
Vue.prototype.n = n
-Vue.use(VTooltip)
+Vue.use(VTooltipPlugin)
// Init Sharing tab component
const View = Vue.extend(VersionTab)
@@ -59,6 +45,12 @@ window.addEventListener('DOMContentLoaded', function() {
update(fileInfo) {
TabInstance.update(fileInfo)
},
+ setIsActive(isActive) {
+ if (!TabInstance) {
+ return
+ }
+ TabInstance.setIsActive(isActive)
+ },
destroy() {
TabInstance.$destroy()
TabInstance = null
diff --git a/apps/files_versions/src/utils/davClient.js b/apps/files_versions/src/utils/davClient.js
index e4bfeb10411..029373e9193 100644
--- a/apps/files_versions/src/utils/davClient.js
+++ b/apps/files_versions/src/utils/davClient.js
@@ -1,34 +1,29 @@
/**
- * @copyright 2022 Louis Chemineau <mlouis@chmn.me>
- *
- * @author Louis Chemineau <mlouis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { createClient, getPatcher } from 'webdav'
+import { createClient } from 'webdav'
import { generateRemoteUrl } from '@nextcloud/router'
-import axios from '@nextcloud/axios'
+import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
+// init webdav client
const rootPath = 'dav'
+const remote = generateRemoteUrl(rootPath)
+const client = createClient(remote)
-// force our axios
-const patcher = getPatcher()
-patcher.patch('request', axios)
+// set CSRF token header
+const setHeaders = (token) => {
+ client.setHeaders({
+ // Add this so the server knows it is an request from the browser
+ 'X-Requested-With': 'XMLHttpRequest',
+ // Inject user auth
+ requesttoken: token ?? '',
+ })
+}
-// init webdav client on default dav endpoint
-const remote = generateRemoteUrl(rootPath)
-export default createClient(remote)
+// refresh headers when request token changes
+onRequestTokenUpdate(setHeaders)
+setHeaders(getRequestToken())
+
+export default client
diff --git a/apps/files_versions/src/utils/davRequest.js b/apps/files_versions/src/utils/davRequest.js
index fb2126d98bf..1dcf620564a 100644
--- a/apps/files_versions/src/utils/davRequest.js
+++ b/apps/files_versions/src/utils/davRequest.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default `<?xml version="1.0"?>
@@ -29,6 +12,9 @@ export default `<?xml version="1.0"?>
<d:getcontentlength />
<d:getcontenttype />
<d:getlastmodified />
+ <d:getetag />
<nc:version-label />
+ <nc:version-author />
+ <nc:has-preview />
</d:prop>
</d:propfind>`
diff --git a/apps/files_versions/src/utils/logger.js b/apps/files_versions/src/utils/logger.js
index 4f0356764d9..f84cb969244 100644
--- a/apps/files_versions/src/utils/logger.js
+++ b/apps/files_versions/src/utils/logger.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2022 Louis Chemineau <mlouis@chmn.me>
- *
- * @author Louis Chemineau <mlouis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
diff --git a/apps/files_versions/src/utils/versions.js b/apps/files_versions/src/utils/versions.js
deleted file mode 100644
index 1a5dde10824..00000000000
--- a/apps/files_versions/src/utils/versions.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * @copyright 2022 Louis Chemineau <mlouis@chmn.me>
- *
- * @author Louis Chemineau <mlouis@chmn.me>
- *
- * @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 { getCurrentUser } from '@nextcloud/auth'
-import client from '../utils/davClient.js'
-import davRequest from '../utils/davRequest.js'
-import logger from '../utils/logger.js'
-import { joinPaths } from '@nextcloud/paths'
-import { generateUrl } from '@nextcloud/router'
-import moment from '@nextcloud/moment'
-
-/**
- * @typedef {object} Version
- * @property {string} fileId - The id of the file associated to the version.
- * @property {string} label - 'Current version' or ''
- * @property {string} fileName - File name relative to the version DAV endpoint
- * @property {string} mimeType - Empty for the current version, else the actual mime type of the version
- * @property {string} size - Human readable size
- * @property {string} type - 'file'
- * @property {number} mtime - Version creation date as a timestamp
- * @property {string} preview - Preview URL of the version
- * @property {string} url - Download URL of the version
- * @property {string|null} fileVersion - The version id, null for the current version
- */
-
-/**
- * @param fileInfo
- * @return {Promise<Version[]>}
- */
-export async function fetchVersions(fileInfo) {
- const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}`
-
- try {
- /** @type {import('webdav').ResponseDataDetailed<import('webdav').FileStat[]>} */
- const response = await client.getDirectoryContents(path, {
- data: davRequest,
- details: true,
- })
- return response.data
- // Filter out root
- .filter(({ mime }) => mime !== '')
- .map(version => formatVersion(version, fileInfo))
- } catch (exception) {
- logger.error('Could not fetch version', { exception })
- throw exception
- }
-}
-
-/**
- * Restore the given version
- *
- * @param {Version} version
- */
-export async function restoreVersion(version) {
- try {
- logger.debug('Restoring version', { url: version.url })
- await client.moveFile(
- `/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`,
- `/versions/${getCurrentUser()?.uid}/restore/target`
- )
- } catch (exception) {
- logger.error('Could not restore version', { exception })
- throw exception
- }
-}
-
-/**
- * Format version
- *
- * @param {object} version - raw version received from the versions DAV endpoint
- * @param {object} fileInfo - file properties received from the files DAV endpoint
- * @return {Version}
- */
-function formatVersion(version, fileInfo) {
- return {
- fileId: fileInfo.id,
- label: version.props['version-label'],
- fileName: version.filename,
- mimeType: version.mime,
- size: version.size,
- type: version.type,
- mtime: moment(version.lastmod).unix() * 1000,
- preview: generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', {
- file: joinPaths(fileInfo.path, fileInfo.name),
- fileVersion: version.basename,
- }),
- url: joinPaths('/remote.php/dav', version.filename),
- fileVersion: version.basename,
- }
-}
-
-/**
- * @param {Version} version
- * @param {string} newLabel
- */
-export async function setVersionLabel(version, newLabel) {
- return await client.customRequest(
- version.fileName,
- {
- method: 'PROPPATCH',
- data: `<?xml version="1.0"?>
- <d:propertyupdate xmlns:d="DAV:"
- xmlns:oc="http://owncloud.org/ns"
- xmlns:nc="http://nextcloud.org/ns"
- xmlns:ocs="http://open-collaboration-services.org/ns">
- <d:set>
- <d:prop>
- <nc:version-label>${newLabel}</nc:version-label>
- </d:prop>
- </d:set>
- </d:propertyupdate>`,
- }
- )
-}
-
-/**
- * @param {Version} version
- */
-export async function deleteVersion(version) {
- await client.deleteFile(version.fileName)
-}
diff --git a/apps/files_versions/src/utils/versions.ts b/apps/files_versions/src/utils/versions.ts
new file mode 100644
index 00000000000..6d5933f0bd9
--- /dev/null
+++ b/apps/files_versions/src/utils/versions.ts
@@ -0,0 +1,133 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable jsdoc/require-param */
+/* eslint-disable jsdoc/require-jsdoc */
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { joinPaths, encodePath } from '@nextcloud/paths'
+import moment from '@nextcloud/moment'
+
+import client from '../utils/davClient.js'
+import davRequest from '../utils/davRequest.js'
+import logger from '../utils/logger.js'
+
+export interface Version {
+ fileId: string, // The id of the file associated to the version.
+ label: string, // 'Current version' or ''
+ author: string|null, // UID for the author of the version
+ filename: string, // File name relative to the version DAV endpoint
+ basename: string, // A base name generated from the mtime
+ mime: string, // Empty for the current version, else the actual mime type of the version
+ etag: string, // Empty for the current version, else the actual mime type of the version
+ size: string, // Human readable size
+ type: string, // 'file'
+ mtime: number, // Version creation date as a timestamp
+ permissions: string, // Only readable: 'R'
+ previewUrl: string, // Preview URL of the version
+ url: string, // Download URL of the version
+ source: string, // The WebDAV endpoint of the ressource
+ fileVersion: string|null, // The version id, null for the current version
+}
+
+export async function fetchVersions(fileInfo: any): Promise<Version[]> {
+ const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}`
+
+ try {
+ const response = await client.getDirectoryContents(path, {
+ data: davRequest,
+ details: true,
+ }) as ResponseDataDetailed<FileStat[]>
+
+ return response.data
+ // Filter out root
+ .filter(({ mime }) => mime !== '')
+ .map(version => formatVersion(version, fileInfo))
+ } catch (exception) {
+ logger.error('Could not fetch version', { exception })
+ throw exception
+ }
+}
+
+/**
+ * Restore the given version
+ */
+export async function restoreVersion(version: Version) {
+ try {
+ logger.debug('Restoring version', { url: version.url })
+ await client.moveFile(
+ `/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`,
+ `/versions/${getCurrentUser()?.uid}/restore/target`,
+ )
+ } catch (exception) {
+ logger.error('Could not restore version', { exception })
+ throw exception
+ }
+}
+
+/**
+ * Format version
+ */
+function formatVersion(version: any, fileInfo: any): Version {
+ const mtime = moment(version.lastmod).unix() * 1000
+ let previewUrl = ''
+
+ if (mtime === fileInfo.mtime) { // Version is the current one
+ previewUrl = generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0&forceIcon=1&mimeFallback=1', {
+ fileId: fileInfo.id,
+ fileEtag: fileInfo.etag,
+ })
+ } else {
+ previewUrl = generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}&mimeFallback=1', {
+ file: joinPaths(fileInfo.path, fileInfo.name),
+ fileVersion: version.basename,
+ })
+ }
+
+ return {
+ fileId: fileInfo.id,
+ // If version-label is defined make sure it is a string (prevent issue if the label is a number an PHP returns a number then)
+ label: version.props['version-label'] && String(version.props['version-label']),
+ author: version.props['version-author'] ?? null,
+ filename: version.filename,
+ basename: moment(mtime).format('LLL'),
+ mime: version.mime,
+ etag: `${version.props.getetag}`,
+ size: version.size,
+ type: version.type,
+ mtime,
+ permissions: 'R',
+ previewUrl,
+ url: joinPaths('/remote.php/dav', version.filename),
+ source: generateRemoteUrl('dav') + encodePath(version.filename),
+ fileVersion: version.basename,
+ }
+}
+
+export async function setVersionLabel(version: Version, newLabel: string) {
+ return await client.customRequest(
+ version.filename,
+ {
+ method: 'PROPPATCH',
+ data: `<?xml version="1.0"?>
+ <d:propertyupdate xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns"
+ xmlns:ocs="http://open-collaboration-services.org/ns">
+ <d:set>
+ <d:prop>
+ <nc:version-label>${newLabel}</nc:version-label>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>`,
+ },
+ )
+}
+
+export async function deleteVersion(version: Version) {
+ await client.deleteFile(version.filename)
+}
diff --git a/apps/files_versions/src/views/VersionTab.vue b/apps/files_versions/src/views/VersionTab.vue
index f2e9576abd0..a643aef439d 100644
--- a/apps/files_versions/src/views/VersionTab.vue
+++ b/apps/files_versions/src/views/VersionTab.vue
@@ -1,58 +1,90 @@
<!--
- - @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <ul data-files-versions-versions-list>
- <Version v-for="version in orderedVersions"
- :key="version.mtime"
- :version="version"
- :file-info="fileInfo"
- :is-current="version.mtime === fileInfo.mtime"
- :is-first-version="version.mtime === initialVersionMtime"
- @restore="handleRestore"
- @label-update="handleLabelUpdate"
- @delete="handleDelete" />
- </ul>
+ <div class="versions-tab__container">
+ <VirtualScrolling v-slot="{ visibleSections }"
+ :sections="sections"
+ :header-height="0">
+ <ul :aria-label="t('files_versions', 'File versions')" data-files-versions-versions-list>
+ <template v-if="visibleSections.length === 1">
+ <Version v-for="(row) of visibleSections[0].rows"
+ :key="row.items[0].mtime"
+ :can-view="canView"
+ :can-compare="canCompare"
+ :load-preview="isActive"
+ :version="row.items[0]"
+ :file-info="fileInfo"
+ :is-current="row.items[0].mtime === fileInfo.mtime"
+ :is-first-version="row.items[0].mtime === initialVersionMtime"
+ @click="openVersion"
+ @compare="compareVersion"
+ @restore="handleRestore"
+ @label-update-request="handleLabelUpdateRequest(row.items[0])"
+ @delete="handleDelete" />
+ </template>
+ </ul>
+ <NcLoadingIcon v-if="loading" slot="loader" class="files-list-viewer__loader" />
+ </VirtualScrolling>
+ <VersionLabelDialog v-if="editedVersion"
+ :open.sync="showVersionLabelForm"
+ :version-label="editedVersion.label"
+ @label-update="handleLabelUpdate" />
+ </div>
</template>
<script>
+import path from 'path'
+
+import { getCurrentUser } from '@nextcloud/auth'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.js'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.ts'
import Version from '../components/Version.vue'
+import VirtualScrolling from '../components/VirtualScrolling.vue'
+import VersionLabelDialog from '../components/VersionLabelDialog.vue'
export default {
name: 'VersionTab',
components: {
Version,
+ VirtualScrolling,
+ VersionLabelDialog,
+ NcLoadingIcon,
+ },
+
+ setup() {
+ return {
+ isMobile: useIsMobile(),
+ }
},
+
data() {
return {
fileInfo: null,
- /** @type {import('../utils/versions.js').Version[]} */
+ isActive: false,
+ /** @type {import('../utils/versions.ts').Version[]} */
versions: [],
loading: false,
+ showVersionLabelForm: false,
+ editedVersion: null,
}
},
computed: {
+ sections() {
+ const rows = this.orderedVersions.map(version => ({ key: version.mtime, height: 68, sectionKey: 'versions', items: [version] }))
+ return [{ key: 'versions', rows, height: 68 * this.orderedVersions.length }]
+ },
+
/**
* Order versions by mtime.
* Put the current version at the top.
*
- * @return {import('../utils/versions.js').Version[]}
+ * @return {import('../utils/versions.ts').Version[]}
*/
orderedVersions() {
return [...this.versions].sort((a, b) => {
@@ -68,6 +100,7 @@ export default {
/**
* Return the mtime of the first version to display "Initial version" label
+ *
* @return {number}
*/
initialVersionMtime() {
@@ -75,6 +108,43 @@ export default {
.map(version => version.mtime)
.reduce((a, b) => Math.min(a, b))
},
+
+ viewerFileInfo() {
+ // We need to remap bitmask to dav permissions as the file info we have is converted through client.js
+ let davPermissions = ''
+ if (this.fileInfo.permissions & 1) {
+ davPermissions += 'R'
+ }
+ if (this.fileInfo.permissions & 2) {
+ davPermissions += 'W'
+ }
+ if (this.fileInfo.permissions & 8) {
+ davPermissions += 'D'
+ }
+ return {
+ ...this.fileInfo,
+ mime: this.fileInfo.mimetype,
+ basename: this.fileInfo.name,
+ filename: this.fileInfo.path + '/' + this.fileInfo.name,
+ permissions: davPermissions,
+ fileid: this.fileInfo.id,
+ }
+ },
+
+ /** @return {boolean} */
+ canView() {
+ return window.OCA.Viewer?.mimetypesCompare?.includes(this.fileInfo.mimetype)
+ },
+
+ canCompare() {
+ return !this.isMobile
+ },
+ },
+ mounted() {
+ subscribe('files_versions:restore:restored', this.fetchVersions)
+ },
+ beforeUnmount() {
+ unsubscribe('files_versions:restore:restored', this.fetchVersions)
},
methods: {
/**
@@ -89,6 +159,13 @@ export default {
},
/**
+ * @param {boolean} isActive whether the tab is active
+ */
+ async setIsActive(isActive) {
+ this.isActive = isActive
+ },
+
+ /**
* Get the existing versions infos
*/
async fetchVersions() {
@@ -103,7 +180,7 @@ export default {
/**
* Handle restored event from Version.vue
*
- * @param {import('../utils/versions.js').Version} version
+ * @param {import('../utils/versions.ts').Version} version The version to restore
*/
async handleRestore(version) {
// Update local copy of fileInfo as rendering depends on it.
@@ -114,45 +191,65 @@ export default {
mtime: version.mtime,
}
+ const restoreStartedEventState = {
+ preventDefault: false,
+ fileInfo: this.fileInfo,
+ version,
+ }
+ emit('files_versions:restore:requested', restoreStartedEventState)
+ if (restoreStartedEventState.preventDefault) {
+ return
+ }
+
try {
await restoreVersion(version)
- if (version.label !== '') {
+ if (version.label) {
showSuccess(t('files_versions', `${version.label} restored`))
} else if (version.mtime === this.initialVersionMtime) {
showSuccess(t('files_versions', 'Initial version restored'))
} else {
showSuccess(t('files_versions', 'Version restored'))
}
- await this.fetchVersions()
+ emit('files_versions:restore:restored', version)
} catch (exception) {
this.fileInfo = oldFileInfo
showError(t('files_versions', 'Could not restore version'))
+ emit('files_versions:restore:failed', version)
}
},
/**
* Handle label-updated event from Version.vue
- *
- * @param {import('../utils/versions.js').Version} version
- * @param {string} newName
+ * @param {import('../utils/versions.ts').Version} version The version to update
*/
- async handleLabelUpdate(version, newName) {
- const oldLabel = version.label
- version.label = newName
+ handleLabelUpdateRequest(version) {
+ this.showVersionLabelForm = true
+ this.editedVersion = version
+ },
+
+ /**
+ * Handle label-updated event from Version.vue
+ * @param {string} newLabel The new label
+ */
+ async handleLabelUpdate(newLabel) {
+ const oldLabel = this.editedVersion.label
+ this.editedVersion.label = newLabel
+ this.showVersionLabelForm = false
try {
- await setVersionLabel(version, newName)
+ await setVersionLabel(this.editedVersion, newLabel)
+ this.editedVersion = null
} catch (exception) {
- version.label = oldLabel
- showError(t('files_versions', 'Could not set version name'))
+ this.editedVersion.label = oldLabel
+ showError(this.t('files_versions', 'Could not set version label'))
+ logger.error('Could not set version label', { exception })
}
},
/**
* Handle deleted event from Version.vue
*
- * @param {import('../utils/versions.js').Version} version
- * @param {string} newName
+ * @param {import('../utils/versions.ts').Version} version The version to delete
*/
async handleDelete(version) {
const index = this.versions.indexOf(version)
@@ -172,6 +269,39 @@ export default {
resetState() {
this.$set(this, 'versions', [])
},
+
+ openVersion({ version }) {
+ // Open current file view instead of read only
+ if (version.mtime === this.fileInfo.mtime) {
+ OCA.Viewer.open({ fileInfo: this.viewerFileInfo })
+ return
+ }
+
+ // Versions previews are too small for our use case, so we override previewUrl
+ // which makes the viewer render the original file.
+ // We also point to the original filename if the version is the current one.
+ const versions = this.versions.map(version => ({
+ ...version,
+ filename: version.mtime === this.fileInfo.mtime ? path.join('files', getCurrentUser()?.uid ?? '', this.fileInfo.path, this.fileInfo.name) : version.filename,
+ previewUrl: undefined,
+ }))
+
+ OCA.Viewer.open({
+ fileInfo: versions.find(v => v.source === version.source),
+ enableSidebar: false,
+ })
+ },
+
+ compareVersion({ version }) {
+ const versions = this.versions.map(version => ({ ...version, previewUrl: undefined }))
+
+ OCA.Viewer.compare(this.viewerFileInfo, versions.find(v => v.source === version.source))
+ },
},
}
</script>
+<style lang="scss">
+.versions-tab__container {
+ height: 100%;
+}
+</style>