aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_versions/src
diff options
context:
space:
mode:
authorLouis Chemineau <louis@chmn.me>2022-11-29 14:52:02 +0100
committerLouis (Rebase PR Action) <artonge@users.noreply.github.com>2023-01-26 10:12:23 +0000
commit629de6c8c97f9a455944046737421b927b1e2d9b (patch)
tree25dc4a6e6dd5a339d3207a8c8fb325ecb71c1cbf /apps/files_versions/src
parente82bfba114aa291fe6bbe1de3488c685d12489a1 (diff)
downloadnextcloud-server-629de6c8c97f9a455944046737421b927b1e2d9b.tar.gz
nextcloud-server-629de6c8c97f9a455944046737421b927b1e2d9b.zip
Support getting and patching version-label
Signed-off-by: Louis Chemineau <louis@chmn.me>
Diffstat (limited to 'apps/files_versions/src')
-rw-r--r--apps/files_versions/src/components/Version.vue302
-rw-r--r--apps/files_versions/src/files_versions_tab.js2
-rw-r--r--apps/files_versions/src/utils/davRequest.js1
-rw-r--r--apps/files_versions/src/utils/versions.js89
-rw-r--r--apps/files_versions/src/views/VersionTab.vue205
5 files changed, 456 insertions, 143 deletions
diff --git a/apps/files_versions/src/components/Version.vue b/apps/files_versions/src/components/Version.vue
new file mode 100644
index 00000000000..a06d6a0ba8e
--- /dev/null
+++ b/apps/files_versions/src/components/Version.vue
@@ -0,0 +1,302 @@
+<!--
+ - @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/>.
+ -->
+<template>
+ <div>
+ <NcListItem class="version"
+ :title="versionLabel"
+ :href="downloadURL"
+ :force-display-actions="true">
+ <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>
+ </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"
+ :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>
+
+ <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>
+</template>
+
+<script>
+import BackupRestore from 'vue-material-design-icons/BackupRestore.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'
+
+export default {
+ name: 'Version',
+ components: {
+ NcActionLink,
+ NcActionButton,
+ NcListItem,
+ NcModal,
+ NcButton,
+ NcTextField,
+ BackupRestore,
+ Download,
+ Pencil,
+ Check,
+ Delete,
+ },
+ 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,
+ required: true,
+ },
+ fileInfo: {
+ type: Object,
+ required: true,
+ },
+ isCurrent: {
+ type: Boolean,
+ default: false,
+ },
+ isFirstVersion: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ showVersionLabelForm: false,
+ formVersionLabelValue: this.version.label,
+ capabilities: loadState('core', 'capabilities', { files: { version_labeling: false } }),
+ }
+ },
+ computed: {
+ /**
+ * @return {string}
+ */
+ versionLabel() {
+ if (this.isCurrent) {
+ if (this.version.label === '') {
+ return translate('files_versions', 'Current version')
+ } else {
+ return `${this.version.label} (${translate('files_versions', 'Current version')})`
+ }
+ }
+
+ if (this.isFirstVersion && this.version.label === '') {
+ return translate('files_versions', 'Initial version')
+ }
+
+ return this.version.label
+ },
+
+ /**
+ * @return {string}
+ */
+ downloadURL() {
+ if (this.isCurrent) {
+ return joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name)
+ } else {
+ return 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
+ }
+ },
+ },
+ methods: {
+ openVersionLabelModal() {
+ this.showVersionLabelForm = true
+ this.$nextTick(() => {
+ this.$refs.labelInput.$el.getElementsByTagName('input')[0].focus()
+ })
+ },
+
+ restoreVersion() {
+ this.$emit('restore', this.version)
+ },
+
+ setVersionLabel(label) {
+ this.formVersionLabelValue = label
+ this.showVersionLabelForm = false
+ this.$emit('label-update', this.version, label)
+ },
+
+ deleteVersion() {
+ this.$emit('delete', this.version)
+ },
+
+ formattedDate() {
+ return moment(this.version.mtime)
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.version {
+ display: flex;
+ flex-direction: row;
+
+ &__info {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+
+ &__size {
+ color: var(--color-text-lighter);
+ }
+ }
+
+ &__image {
+ width: 3rem;
+ 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 {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 64px;
+ }
+}
+</style>
diff --git a/apps/files_versions/src/files_versions_tab.js b/apps/files_versions/src/files_versions_tab.js
index 8482247e672..e67199436fa 100644
--- a/apps/files_versions/src/files_versions_tab.js
+++ b/apps/files_versions/src/files_versions_tab.js
@@ -41,7 +41,7 @@ window.addEventListener('DOMContentLoaded', function() {
OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab({
id: 'version_vue',
- name: t('files_versions', 'Version'),
+ name: t('files_versions', 'Versions'),
iconSvg: BackupRestore,
async mount(el, fileInfo, context) {
diff --git a/apps/files_versions/src/utils/davRequest.js b/apps/files_versions/src/utils/davRequest.js
index b77cd150643..fb2126d98bf 100644
--- a/apps/files_versions/src/utils/davRequest.js
+++ b/apps/files_versions/src/utils/davRequest.js
@@ -29,5 +29,6 @@ export default `<?xml version="1.0"?>
<d:getcontentlength />
<d:getcontenttype />
<d:getlastmodified />
+ <nc:version-label />
</d:prop>
</d:propfind>`
diff --git a/apps/files_versions/src/utils/versions.js b/apps/files_versions/src/utils/versions.js
index 8fe258119f7..1a5dde10824 100644
--- a/apps/files_versions/src/utils/versions.js
+++ b/apps/files_versions/src/utils/versions.js
@@ -23,14 +23,14 @@ import { getCurrentUser } from '@nextcloud/auth'
import client from '../utils/davClient.js'
import davRequest from '../utils/davRequest.js'
import logger from '../utils/logger.js'
-import { basename, joinPaths } from '@nextcloud/paths'
+import { joinPaths } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
-import { translate } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
/**
* @typedef {object} Version
- * @property {string} title - 'Current version' or ''
+ * @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
@@ -39,7 +39,6 @@ import moment from '@nextcloud/moment'
* @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
- * @property {boolean} isCurrent - Whether this is the current version of the file
*/
/**
@@ -50,11 +49,15 @@ export async function fetchVersions(fileInfo) {
const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}`
try {
- /** @type {import('webdav').FileStat[]} */
+ /** @type {import('webdav').ResponseDataDetailed<import('webdav').FileStat[]>} */
const response = await client.getDirectoryContents(path, {
data: davRequest,
+ details: true,
})
- return response.map(version => formatVersion(version, fileInfo))
+ 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
@@ -65,13 +68,12 @@ export async function fetchVersions(fileInfo) {
* Restore the given version
*
* @param {Version} version
- * @param {object} fileInfo
*/
-export async function restoreVersion(version, fileInfo) {
+export async function restoreVersion(version) {
try {
logger.debug('Restoring version', { url: version.url })
await client.moveFile(
- `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}/${version.fileVersion}`,
+ `/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`,
`/versions/${getCurrentUser()?.uid}/restore/target`
)
} catch (exception) {
@@ -88,37 +90,50 @@ export async function restoreVersion(version, fileInfo) {
* @return {Version}
*/
function formatVersion(version, fileInfo) {
- const isCurrent = version.mime === ''
- const fileVersion = isCurrent ? null : basename(version.filename)
-
- let url = null
- let preview = null
-
- if (isCurrent) {
- // https://nextcloud_server2.test/remote.php/webdav/welcome.txt?downloadStartSecret=hl5awd7tbzg
- url = joinPaths('/remote.php/webdav', fileInfo.path, fileInfo.name)
- preview = generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', {
- fileId: fileInfo.id,
- fileEtag: fileInfo.etag,
- })
- } else {
- url = joinPaths('/remote.php/dav', version.filename)
- preview = generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', {
- file: joinPaths(fileInfo.path, fileInfo.name),
- fileVersion,
- })
- }
-
return {
- title: isCurrent ? translate('files_versions', 'Current version') : '',
+ fileId: fileInfo.id,
+ label: version.props['version-label'],
fileName: version.filename,
mimeType: version.mime,
- size: isCurrent ? fileInfo.size : version.size,
+ size: version.size,
type: version.type,
- mtime: moment(isCurrent ? fileInfo.mtime : version.lastmod).unix(),
- preview,
- url,
- fileVersion,
- isCurrent,
+ 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/views/VersionTab.vue b/apps/files_versions/src/views/VersionTab.vue
index 8159415dfc7..5c078ea405b 100644
--- a/apps/files_versions/src/views/VersionTab.vue
+++ b/apps/files_versions/src/views/VersionTab.vue
@@ -16,84 +16,28 @@
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
- <div>
- <ul>
- <NcListItem v-for="version in versions"
- :key="version.mtime"
- class="version"
- :title="version.title"
- :href="version.url">
- <template #icon>
- <img lazy="true"
- :src="version.preview"
- alt=""
- height="256"
- width="256"
- class="version__image">
- </template>
- <template #subtitle>
- <div class="version__info">
- <span>{{ version.mtime | humanDateFromNow }}</span>
- <!-- Separate dot to improve alignement -->
- <span class="version__info__size">•</span>
- <span class="version__info__size">{{ version.size | humanReadableSize }}</span>
- </div>
- </template>
- <template v-if="!version.isCurrent" #actions>
- <NcActionLink :href="version.url"
- :download="version.url">
- <template #icon>
- <Download :size="22" />
- </template>
- {{ t('files_versions', 'Download version') }}
- </NcActionLink>
- <NcActionButton @click="restoreVersion(version)">
- <template #icon>
- <BackupRestore :size="22" />
- </template>
- {{ t('files_versions', 'Restore version') }}
- </NcActionButton>
- </template>
- </NcListItem>
- <NcEmptyContent v-if="!loading && versions.length === 1"
- :title="t('files_version', 'No versions yet')">
- <!-- length === 1, since we don't want to show versions if there is only the current file -->
- <template #icon>
- <BackupRestore />
- </template>
- </NcEmptyContent>
- </ul>
- </div>
+ <ul>
+ <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>
</template>
<script>
-import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
-import Download from 'vue-material-design-icons/Download.vue'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
-import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { fetchVersions, restoreVersion } from '../utils/versions.js'
-import moment from '@nextcloud/moment'
+import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.js'
+import Version from '../components/Version.vue'
export default {
name: 'VersionTab',
components: {
- NcEmptyContent,
- NcActionLink,
- NcActionButton,
- NcListItem,
- BackupRestore,
- Download,
- },
- filters: {
- humanReadableSize(bytes) {
- return OC.Util.humanFileSize(bytes)
- },
- humanDateFromNow(timestamp) {
- return moment(timestamp * 1000).fromNow()
- },
+ Version,
},
data() {
return {
@@ -103,6 +47,35 @@ export default {
loading: false,
}
},
+ computed: {
+ /**
+ * Order versions by mtime.
+ * Put the current version at the top.
+ *
+ * @return {import('../utils/versions.js').Version[]}
+ */
+ orderedVersions() {
+ return [...this.versions].sort((a, b) => {
+ if (a.mtime === this.fileInfo.mtime) {
+ return -1
+ } else if (b.mtime === this.fileInfo.mtime) {
+ return 1
+ } else {
+ return b.mtime - a.mtime
+ }
+ })
+ },
+
+ /**
+ * Return the mtime of the first version to display "Initial version" label
+ * @return {number}
+ */
+ initialVersionMtime() {
+ return this.versions
+ .map(version => version.mtime)
+ .reduce((a, b) => Math.min(a, b))
+ },
+ },
methods: {
/**
* Update current fileInfo and fetch new data
@@ -128,55 +101,77 @@ export default {
},
/**
- * Restore the given version
+ * Handle restored event from Version.vue
*
- * @param version
+ * @param {import('../utils/versions.js').Version} version
*/
- async restoreVersion(version) {
+ async handleRestore(version) {
+ // Update local copy of fileInfo as rendering depends on it.
+ const oldFileInfo = this.fileInfo
+ this.fileInfo = {
+ ...this.fileInfo,
+ size: version.size,
+ mtime: version.mtime,
+ }
+
try {
- await restoreVersion(version, this.fileInfo)
- // File info is not updated so we manually update its size and mtime if the restoration went fine.
- this.fileInfo.size = version.size
- this.fileInfo.mtime = version.lastmod
- showSuccess(t('files_versions', 'Version restored'))
+ await restoreVersion(version)
+ 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()
} catch (exception) {
+ this.fileInfo = oldFileInfo
showError(t('files_versions', 'Could not restore version'))
}
},
/**
+ * Handle label-updated event from Version.vue
+ *
+ * @param {import('../utils/versions.js').Version} version
+ * @param {string} newName
+ */
+ async handleLabelUpdate(version, newName) {
+ const oldLabel = version.label
+ version.label = newName
+
+ try {
+ await setVersionLabel(version, newName)
+ } catch (exception) {
+ version.label = oldLabel
+ showError(t('files_versions', 'Could not set version name'))
+ }
+ },
+
+ /**
+ * Handle deleted event from Version.vue
+ *
+ * @param {import('../utils/versions.js').Version} version
+ * @param {string} newName
+ */
+ async handleDelete(version) {
+ const index = this.versions.indexOf(version)
+ this.versions.splice(index, 1)
+
+ try {
+ await deleteVersion(version)
+ } catch (exception) {
+ this.versions.push(version)
+ showError(t('files_versions', 'Could not delete version'))
+ }
+ },
+
+ /**
* Reset the current view to its default state
*/
resetState() {
- this.versions = []
+ this.$set(this, 'versions', [])
},
},
}
</script>
-
-<style scopped lang="scss">
-.version {
- display: flex;
- flex-direction: row;
-
- &__info {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 0.5rem;
-
- &__size {
- color: var(--color-text-lighter);
- }
- }
-
- &__image {
- width: 3rem;
- height: 3rem;
- border: 1px solid var(--color-border);
- margin-right: 1rem;
- border-radius: var(--border-radius-large);
- }
-}
-</style>