diff options
author | Louis Chemineau <louis@chmn.me> | 2022-11-29 14:52:02 +0100 |
---|---|---|
committer | Louis (Rebase PR Action) <artonge@users.noreply.github.com> | 2023-01-26 10:12:23 +0000 |
commit | 629de6c8c97f9a455944046737421b927b1e2d9b (patch) | |
tree | 25dc4a6e6dd5a339d3207a8c8fb325ecb71c1cbf /apps/files_versions/src | |
parent | e82bfba114aa291fe6bbe1de3488c685d12489a1 (diff) | |
download | nextcloud-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.vue | 302 | ||||
-rw-r--r-- | apps/files_versions/src/files_versions_tab.js | 2 | ||||
-rw-r--r-- | apps/files_versions/src/utils/davRequest.js | 1 | ||||
-rw-r--r-- | apps/files_versions/src/utils/versions.js | 89 | ||||
-rw-r--r-- | apps/files_versions/src/views/VersionTab.vue | 205 |
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> |