Signed-off-by: Louis Chemineau <louis@chmn.me>tags/v26.0.0beta1
@@ -0,0 +1,34 @@ | |||
/** | |||
* @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 { createClient, getPatcher } from 'webdav' | |||
import { generateRemoteUrl } from '@nextcloud/router' | |||
import axios from '@nextcloud/axios' | |||
const rootPath = 'dav' | |||
// force our axios | |||
const patcher = getPatcher() | |||
patcher.patch('request', axios) | |||
// init webdav client on default dav endpoint | |||
const remote = generateRemoteUrl(rootPath) | |||
export default createClient(remote) |
@@ -0,0 +1,33 @@ | |||
/** | |||
* @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/>. | |||
* | |||
*/ | |||
export default `<?xml version="1.0"?> | |||
<d:propfind xmlns:d="DAV:" | |||
xmlns:oc="http://owncloud.org/ns" | |||
xmlns:nc="http://nextcloud.org/ns" | |||
xmlns:ocs="http://open-collaboration-services.org/ns"> | |||
<d:prop> | |||
<d:getcontentlength /> | |||
<d:getcontenttype /> | |||
<d:getlastmodified /> | |||
</d:prop> | |||
</d:propfind>` |
@@ -0,0 +1,27 @@ | |||
/** | |||
* @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 { getLoggerBuilder } from '@nextcloud/logger' | |||
export default getLoggerBuilder() | |||
.setApp('files_version') | |||
.detectUser() | |||
.build() |
@@ -0,0 +1,124 @@ | |||
/** | |||
* @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 { basename, 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} 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 | |||
* @property {boolean} isCurrent - Whether this is the current version of the file | |||
*/ | |||
/** | |||
* @param fileInfo | |||
* @return {Promise<Version[]>} | |||
*/ | |||
export async function fetchVersions(fileInfo) { | |||
const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}` | |||
try { | |||
/** @type {import('webdav').FileStat[]} */ | |||
const response = await client.getDirectoryContents(path, { | |||
data: davRequest, | |||
}) | |||
return response.map(version => formatVersion(version, fileInfo)) | |||
} catch (exception) { | |||
logger.error('Could not fetch version', { exception }) | |||
throw exception | |||
} | |||
} | |||
/** | |||
* Restore the given version | |||
* | |||
* @param {Version} version | |||
* @param {object} fileInfo | |||
*/ | |||
export async function restoreVersion(version, fileInfo) { | |||
try { | |||
logger.debug('Restoring version', { url: version.url }) | |||
await client.moveFile( | |||
`/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}/${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) { | |||
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') : '', | |||
fileName: version.filename, | |||
mimeType: version.mime, | |||
size: isCurrent ? fileInfo.size : version.size, | |||
type: version.type, | |||
mtime: moment(isCurrent ? fileInfo.mtime : version.lastmod).unix(), | |||
preview, | |||
url, | |||
fileVersion, | |||
isCurrent, | |||
} | |||
} |
@@ -19,7 +19,7 @@ | |||
<div> | |||
<ul> | |||
<NcListItem v-for="version in versions" | |||
:key="version.dateTime.unix()" | |||
:key="version.mtime" | |||
class="version" | |||
:title="version.title" | |||
:href="version.url"> | |||
@@ -29,25 +29,25 @@ | |||
alt="" | |||
height="256" | |||
width="256" | |||
class="version-image"> | |||
class="version__image"> | |||
</template> | |||
<template #subtitle> | |||
<div class="version-info"> | |||
<a v-tooltip="version.dateTime" :href="version.url">{{ version.relativeTime }}</a> | |||
<span class="version-info-size">•</span> | |||
<span class="version-info-size"> | |||
{{ version.size }} | |||
</span> | |||
<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 #actions> | |||
<NcActionLink :href="version.url"> | |||
<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 v-if="!version.isCurrent" @click="restoreVersion(version)"> | |||
<NcActionButton @click="restoreVersion(version)"> | |||
<template #icon> | |||
<BackupRestore :size="22" /> | |||
</template> | |||
@@ -67,10 +67,6 @@ | |||
</template> | |||
<script> | |||
import { createClient, getPatcher } from 'webdav' | |||
import axios from '@nextcloud/axios' | |||
import { generateRemoteUrl, generateUrl } from '@nextcloud/router' | |||
import { getCurrentUser } from '@nextcloud/auth' | |||
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' | |||
@@ -78,78 +74,8 @@ 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 { basename, joinPaths } from '@nextcloud/paths' | |||
import { getLoggerBuilder } from '@nextcloud/logger' | |||
import { translate } from '@nextcloud/l10n' | |||
const logger = getLoggerBuilder() | |||
.setApp('files_version') | |||
.detectUser() | |||
.build() | |||
/** | |||
* Get WebDAV request body for version list | |||
*/ | |||
function getDavRequest() { | |||
return `<?xml version="1.0"?> | |||
<d:propfind xmlns:d="DAV:" | |||
xmlns:oc="http://owncloud.org/ns" | |||
xmlns:nc="http://nextcloud.org/ns" | |||
xmlns:ocs="http://open-collaboration-services.org/ns"> | |||
<d:prop> | |||
<d:getcontentlength /> | |||
<d:getcontenttype /> | |||
<d:getlastmodified /> | |||
</d:prop> | |||
</d:propfind>` | |||
} | |||
/** | |||
* Format version | |||
* | |||
* @param version | |||
* @param fileInfo | |||
*/ | |||
function formatVersion(version, fileInfo) { | |||
const fileVersion = basename(version.filename) | |||
const isCurrent = version.mime === '' | |||
const preview = isCurrent | |||
? generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', { | |||
fileId: fileInfo.id, | |||
fileEtag: fileInfo.etag, | |||
}) | |||
: generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', { | |||
file: joinPaths(fileInfo.path, fileInfo.name), | |||
fileVersion, | |||
}) | |||
return { | |||
displayVersionName: fileVersion, | |||
title: isCurrent ? translate('files_versions', 'Current version') : '', | |||
fileName: version.filename, | |||
mimeType: version.mime, | |||
size: OC.Util.humanFileSize(isCurrent ? fileInfo.size : version.size), | |||
type: version.type, | |||
dateTime: moment(isCurrent ? fileInfo.mtime : version.lastmod), | |||
relativeTime: moment(isCurrent ? fileInfo.mtime : version.lastmod).fromNow(), | |||
preview, | |||
url: isCurrent ? joinPaths('/remote.php/dav', version.filename) : joinPaths('/remote.php/dav', fileInfo.path, fileInfo.name), | |||
fileVersion, | |||
isCurrent, | |||
} | |||
} | |||
const rootPath = 'dav' | |||
// force our axios | |||
const patcher = getPatcher() | |||
patcher.patch('request', axios) | |||
// init webdav client on default dav endpoint | |||
const remote = generateRemoteUrl(rootPath) | |||
const client = createClient(remote) | |||
export default { | |||
name: 'VersionTab', | |||
@@ -161,12 +87,20 @@ export default { | |||
BackupRestore, | |||
Download, | |||
}, | |||
filters: { | |||
humanReadableSize(bytes) { | |||
return OC.Util.humanFileSize(bytes) | |||
}, | |||
humanDateFromNow(timestamp) { | |||
return moment(timestamp * 1000).fromNow() | |||
}, | |||
}, | |||
data() { | |||
return { | |||
fileInfo: null, | |||
/** @type {import('../utils/versions.js').Version[]} */ | |||
versions: [], | |||
loading: true, | |||
loading: false, | |||
} | |||
}, | |||
methods: { | |||
@@ -185,16 +119,10 @@ export default { | |||
* Get the existing versions infos | |||
*/ | |||
async fetchVersions() { | |||
const path = `/versions/${getCurrentUser().uid}/versions/${this.fileInfo.id}` | |||
try { | |||
const response = await client.getDirectoryContents(path, { | |||
data: getDavRequest(), | |||
}) | |||
this.versions = response.map(version => formatVersion(version, this.fileInfo)) | |||
this.loading = false | |||
} catch (exception) { | |||
logger.error('Could not fetch version', { exception }) | |||
this.loading = true | |||
this.versions = await fetchVersions(this.fileInfo) | |||
} finally { | |||
this.loading = false | |||
} | |||
}, | |||
@@ -206,15 +134,13 @@ export default { | |||
*/ | |||
async restoreVersion(version) { | |||
try { | |||
logger.debug('restoring version', version.url) | |||
await client.moveFile( | |||
`/versions/${getCurrentUser().uid}/versions/${this.fileInfo.id}/${version.fileVersion}`, | |||
`/versions/${getCurrentUser().uid}/restore/target` | |||
) | |||
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 this.fetchVersions() | |||
} catch (exception) { | |||
logger.error('Could not restore version', { exception }) | |||
showError(t('files_versions', 'Could not restore version')) | |||
} | |||
}, | |||
@@ -233,16 +159,19 @@ export default { | |||
.version { | |||
display: flex; | |||
flex-direction: row; | |||
&-info { | |||
&__info { | |||
display: flex; | |||
flex-direction: row; | |||
align-items: center; | |||
gap: 0.5rem; | |||
&-size { | |||
&__size { | |||
color: var(--color-text-lighter); | |||
} | |||
} | |||
&-image { | |||
&__image { | |||
width: 3rem; | |||
height: 3rem; | |||
border: 1px solid var(--color-border); |