User Data Export and import (#699)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/699
Co-authored-by: konrad <k@knt.li>
Co-committed-by: konrad <k@knt.li>
This commit is contained in:
konrad 2021-09-04 19:26:38 +00:00
parent 44bb7358b6
commit f4c552a79f
17 changed files with 347 additions and 54 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -3,6 +3,23 @@
<h1>{{ $t('migrate.titleService', {name: name}) }}</h1> <h1>{{ $t('migrate.titleService', {name: name}) }}</h1>
<p>{{ $t('migrate.descriptionDo') }}</p> <p>{{ $t('migrate.descriptionDo') }}</p>
<template v-if="isMigrating === false && message === '' && lastMigrationDate === null"> <template v-if="isMigrating === false && message === '' && lastMigrationDate === null">
<template v-if="isFileMigrator">
<p>{{ $t('migrate.importUpload', {name: name}) }}</p>
<input
@change="migrate"
class="is-hidden"
ref="uploadInput"
type="file"
/>
<x-button
:loading="migrationService.loading"
:disabled="migrationService.loading"
@click="$refs.uploadInput.click()"
>
{{ $t('migrate.upload') }}
</x-button>
</template>
<template v-else>
<p>{{ $t('migrate.authorize', {name: name}) }}</p> <p>{{ $t('migrate.authorize', {name: name}) }}</p>
<x-button <x-button
:loading="migrationService.loading" :loading="migrationService.loading"
@ -12,6 +29,7 @@
{{ $t('migrate.getStarted') }} {{ $t('migrate.getStarted') }}
</x-button> </x-button>
</template> </template>
</template>
<div <div
class="migration-in-progress-container" class="migration-in-progress-container"
v-else-if="isMigrating === true && message === '' && lastMigrationDate === null"> v-else-if="isMigrating === true && message === '' && lastMigrationDate === null">
@ -53,7 +71,8 @@
</template> </template>
<script> <script>
import AbstractMigrationService from '../../services/migrator/abstractMigrationService' import AbstractMigrationService from '../../services/migrator/abstractMigration'
import AbstractMigrationFileService from '../../services/migrator/abstractMigrationFile'
export default { export default {
name: 'migration', name: 'migration',
@ -75,11 +94,21 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isFileMigrator: {
type: Boolean,
default: false,
},
}, },
created() { created() {
this.message = ''
if (this.isFileMigrator) {
this.migrationService = new AbstractMigrationFileService(this.identifier)
return
}
this.migrationService = new AbstractMigrationService(this.identifier) this.migrationService = new AbstractMigrationService(this.identifier)
this.getAuthUrl() this.getAuthUrl()
this.message = ''
if (typeof this.$route.query.code !== 'undefined' || location.hash.startsWith('#token=')) { if (typeof this.$route.query.code !== 'undefined' || location.hash.startsWith('#token=')) {
if (location.hash.startsWith('#token=')) { if (location.hash.startsWith('#token=')) {
@ -122,6 +151,11 @@ export default {
this.isMigrating = true this.isMigrating = true
this.lastMigrationDate = null this.lastMigrationDate = null
this.message = '' this.message = ''
if (this.isFileMigrator) {
return this.migrateFile()
}
this.migrationService.migrate({code: this.migratorAuthCode}) this.migrationService.migrate({code: this.migratorAuthCode})
.then(r => { .then(r => {
this.message = r.message this.message = r.message
@ -134,6 +168,23 @@ export default {
this.isMigrating = false this.isMigrating = false
}) })
}, },
migrateFile() {
if (this.$refs.uploadInput.files.length === 0) {
return
}
this.migrationService.migrate(this.$refs.uploadInput.files[0])
.then(r => {
this.message = r.message
this.$store.dispatch('namespaces/loadNamespaces')
})
.catch(e => {
this.error(e)
})
.finally(() => {
this.isMigrating = false
})
},
}, },
} }
</script> </script>

View file

@ -57,7 +57,7 @@
@click.prevent.stop="downloadAttachment(a)" @click.prevent.stop="downloadAttachment(a)"
v-tooltip="$t('task.attachment.downloadTooltip')" v-tooltip="$t('task.attachment.downloadTooltip')"
> >
{{ $t('task.attachment.download') }} {{ $t('misc.download') }}
</a> </a>
<a <a
@click.stop="copyUrl(a)" @click.stop="copyUrl(a)"

View file

@ -0,0 +1,71 @@
<template>
<card :title="$t('user.export.title')">
<p>
{{ $t('user.export.description') }}
</p>
<p>
{{ $t('user.export.descriptionPasswordRequired') }}
</p>
<div class="field">
<label class="label" for="currentPasswordDataExport">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordDataExport"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
<x-button
:loading="dataExportService.loading"
@click="requestDataExport()"
class="is-fullwidth mt-4">
{{ $t('user.export.request') }}
</x-button>
</card>
</template>
<script>
import DataExportService from '../../../services/dataExport'
export default {
name: 'data-export',
data() {
return {
dataExportService: DataExportService,
password: '',
errPasswordRequired: false,
}
},
created() {
this.dataExportService = new DataExportService()
},
methods: {
requestDataExport() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.dataExportService.request(this.password)
.then(() => {
this.success({message: this.$t('user.export.success')})
this.password = ''
})
.catch(e => this.error(e))
},
},
}
</script>

View file

@ -0,0 +1,7 @@
export const downloadBlob = (url: string, filename: string) => {
const link = document.createElement('a')
link.href = url
link.setAttribute('download', filename)
link.click()
window.URL.revokeObjectURL(url)
}

38
src/helpers/migrator.ts Normal file
View file

@ -0,0 +1,38 @@
export interface Migrator {
name: string
identifier: string
isFileMigrator?: boolean
}
export const getMigratorFromSlug = (slug: string): Migrator => {
switch (slug) {
case 'wunderlist':
return {
name: 'Wunderlist',
identifier: 'wunderlist',
}
case 'todoist':
return {
name: 'Todoist',
identifier: 'todoist',
}
case 'trello':
return {
name: 'Trello',
identifier: 'trello',
}
case 'microsoft-todo':
return {
name: 'Microsoft Todo',
identifier: 'microsoft-todo',
}
case 'vikunja-file':
return {
name: 'Vikunja Export',
identifier: 'vikunja-file',
isFileMigrator: true,
}
default:
throw Error('Unknown migrator slug ' + slug)
}
}

View file

@ -111,6 +111,13 @@
"scheduledCancelText": "To cancel the deletion of your account, please enter your password below:", "scheduledCancelText": "To cancel the deletion of your account, please enter your password below:",
"scheduledCancelConfirm": "Cancel the deletion of my account", "scheduledCancelConfirm": "Cancel the deletion of my account",
"scheduledCancelSuccess": "We will not delete your account." "scheduledCancelSuccess": "We will not delete your account."
},
"export": {
"title": "Export your Vikunja Data",
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Lists, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
"descriptionPasswordRequired": "Please enter your password to proceed:",
"request": "Request a copy of my Vikunja Data",
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download."
} }
}, },
"list": { "list": {
@ -371,7 +378,9 @@
"inProgress": "Importing in progress…", "inProgress": "Importing in progress…",
"alreadyMigrated1": "It looks like you've already imported your stuff from {name} at {date}.", "alreadyMigrated1": "It looks like you've already imported your stuff from {name} at {date}.",
"alreadyMigrated2": "Importing again is possible, but might create duplicates. Are you sure?", "alreadyMigrated2": "Importing again is possible, but might create duplicates. Are you sure?",
"confirm": "I am sure, please start migrating now!" "confirm": "I am sure, please start migrating now!",
"importUpload": "To import data from {name} into Vikunja, click the button below to select a file.",
"upload": "Upload file"
}, },
"label": { "label": {
"title": "Labels", "title": "Labels",
@ -432,7 +441,8 @@
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved!", "saved": "Saved!",
"default": "Default", "default": "Default",
"close": "Close" "close": "Close",
"download": "Download"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -563,7 +573,6 @@
"attachment": { "attachment": {
"title": "Attachments", "title": "Attachments",
"createdBy": "created {0} by {1}", "createdBy": "created {0} by {1}",
"download": "Download",
"downloadTooltip": "Download this attachment", "downloadTooltip": "Download this attachment",
"upload": "Upload attachment", "upload": "Upload attachment",
"drop": "Drop files here to upload", "drop": "Drop files here to upload",

View file

@ -10,6 +10,7 @@ import About from '../views/About'
import LoginComponent from '../views/user/Login' import LoginComponent from '../views/user/Login'
import RegisterComponent from '../views/user/Register' import RegisterComponent from '../views/user/Register'
import OpenIdAuth from '../views/user/OpenIdAuth' import OpenIdAuth from '../views/user/OpenIdAuth'
import DataExportDownload from '../views/user/DataExportDownload'
// Tasks // Tasks
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange' import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth' import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth'
@ -149,6 +150,11 @@ export default new Router({
name: 'user.settings', name: 'user.settings',
component: UserSettingsComponent, component: UserSettingsComponent,
}, },
{
path: '/user/export/download',
name: 'user.export.download',
component: DataExportDownload,
},
{ {
path: '/share/:share/auth', path: '/share/:share/auth',
name: 'link-share.auth', name: 'link-share.auth',

View file

@ -319,6 +319,17 @@ export default class AbstractService {
}) })
} }
getBlobUrl(url, method = 'GET', data = {}) {
return this.http({
url: url,
method: method,
responseType: 'blob',
data: data,
}).then(response => {
return window.URL.createObjectURL(new Blob([response.data]))
})
}
/** /**
* Performs a get request to the url specified before. * Performs a get request to the url specified before.
* The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object. * The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object.
@ -487,6 +498,8 @@ export default class AbstractService {
* @returns {Q.Promise<unknown>} * @returns {Q.Promise<unknown>}
*/ */
uploadFormData(url, formData) { uploadFormData(url, formData) {
console.log(formData, formData._boundary)
const cancel = this.setLoading() const cancel = this.setLoading()
return this.http.put( return this.http.put(
url, url,

View file

@ -1,6 +1,7 @@
import AbstractService from './abstractService' import AbstractService from './abstractService'
import AttachmentModel from '../models/attachment' import AttachmentModel from '../models/attachment'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'
import {downloadBlob} from '@/helpers/downloadBlob'
export default class AttachmentService extends AbstractService { export default class AttachmentService extends AbstractService {
constructor() { constructor() {
@ -33,23 +34,12 @@ export default class AttachmentService extends AbstractService {
} }
getBlobUrl(model) { getBlobUrl(model) {
return this.http({ return AbstractService.prototype.getBlobUrl.call(this, '/tasks/' + model.taskId + '/attachments/' + model.id)
url: '/tasks/' + model.taskId + '/attachments/' + model.id,
method: 'GET',
responseType: 'blob',
}).then(response => {
return window.URL.createObjectURL(new Blob([response.data]))
})
} }
download(model) { download(model) {
this.getBlobUrl(model).then(url => { this.getBlobUrl(model)
const link = document.createElement('a') .then(url => downloadBlob(url, model.file.name))
link.href = url
link.setAttribute('download', model.file.name)
link.click()
window.URL.revokeObjectURL(url)
})
} }
/** /**

View file

@ -0,0 +1,13 @@
import AbstractService from './abstractService'
import {downloadBlob} from '../helpers/downloadBlob'
export default class DataExportService extends AbstractService {
request(password) {
return this.post('/user/export/request', {password: password})
}
download(password) {
return this.getBlobUrl('/user/export/download', 'POST', {password})
.then(url => downloadBlob(url, 'vikunja-export.zip'))
}
}

View file

@ -0,0 +1,31 @@
import AbstractService from '../abstractService'
// This service builds on top of the abstract service and basically just hides away method names.
// It enables migration services to be created with minimal overhead and even better method names.
export default class AbstractMigrationFileService extends AbstractService {
serviceUrlKey = ''
constructor(serviceUrlKey) {
super({
create: '/migration/' + serviceUrlKey + '/migrate',
})
this.serviceUrlKey = serviceUrlKey
}
getStatus() {
return this.getM('/migration/' + this.serviceUrlKey + '/status')
}
useCreateInterceptor() {
return false
}
migrate(file) {
console.log(file)
return this.uploadFile(
this.paths.create,
file,
'import',
)
}
}

View file

@ -3,15 +3,20 @@
<h1>{{ $t('migrate.title') }}</h1> <h1>{{ $t('migrate.title') }}</h1>
<p>{{ $t('migrate.description') }}</p> <p>{{ $t('migrate.description') }}</p>
<div class="migration-services-overview"> <div class="migration-services-overview">
<router-link :key="m" :to="{name: 'migrate.service', params: {service: m}}" v-for="m in availableMigrators"> <router-link
<img :alt="m" :src="`/images/migration/${m}.png`"/> :key="m.identifier"
{{ m }} :to="{name: 'migrate.service', params: {service: m.identifier}}"
v-for="m in availableMigrators">
<img :alt="m.name" :src="`/images/migration/${m.identifier}.png`"/>
{{ m.name }}
</router-link> </router-link>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import {getMigratorFromSlug} from '../../helpers/migrator'
export default { export default {
name: 'migrate.service', name: 'migrate.service',
mounted() { mounted() {
@ -19,7 +24,7 @@ export default {
}, },
computed: { computed: {
availableMigrators() { availableMigrators() {
return this.$store.state.config.availableMigrators return this.$store.state.config.availableMigrators.map(getMigratorFromSlug)
}, },
}, },
} }

View file

@ -2,12 +2,13 @@
<migration <migration
:identifier="identifier" :identifier="identifier"
:name="name" :name="name"
:is-file-migrator="isFileMigrator"
/> />
</template> </template>
<script> <script>
import Migration from '../../components/migrator/migration' import Migration from '../../components/migrator/migration'
import router from '../../router' import {getMigratorFromSlug} from '../../helpers/migrator'
export default { export default {
name: 'migrateService', name: 'migrateService',
@ -18,31 +19,20 @@ export default {
return { return {
name: '', name: '',
identifier: '', identifier: '',
isFileMigrator: false,
} }
}, },
mounted() { mounted() {
this.setTitle(this.$t('migrate.titleService', {name: this.name})) this.setTitle(this.$t('migrate.titleService', {name: this.name}))
}, },
created() { created() {
switch (this.$route.params.service) { try {
case 'wunderlist': const {name, identifier, isFileMigrator} = getMigratorFromSlug(this.$route.params.service)
this.name = 'Wunderlist' this.name = name
this.identifier = 'wunderlist' this.identifier = identifier
break this.isFileMigrator = isFileMigrator
case 'todoist': } catch (e) {
this.name = 'Todoist' this.$router.push({name: '404'})
this.identifier = 'todoist'
break
case 'trello':
this.name = 'Trello'
this.identifier = 'trello'
break
case 'microsoft-todo':
this.name = 'Microsoft Todo'
this.identifier = 'microsoft-todo'
break
default:
router.push({name: '404'})
} }
}, },
} }

View file

@ -0,0 +1,64 @@
<template>
<div class="content">
<h1>{{ $t('user.export.downloadTitle') }}</h1>
<p>{{ $t('user.export.descriptionPasswordRequired') }}</p>
<div class="field">
<label class="label" for="currentPasswordDataExport">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordDataExport"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
<x-button
v-focus
:loading="dataExportService.loading"
@click="download()"
class="mt-4">
{{ $t('misc.download') }}
</x-button>
</div>
</template>
<script>
import DataExportService from '../../services/dataExport'
export default {
name: 'data-export-download',
data() {
return {
dataExportService: DataExportService,
password: '',
errPasswordRequired: false,
}
},
created() {
this.dataExportService = new DataExportService()
},
methods: {
download() {
if (this.password === '') {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
this.dataExportService.download(this.password)
.catch(e => this.error(e))
},
},
}
</script>

View file

@ -236,6 +236,9 @@
</template> </template>
</card> </card>
<!-- Data export -->
<data-export/>
<!-- Migration --> <!-- Migration -->
<card :title="$t('migrate.title')" v-if="migratorsEnabled"> <card :title="$t('migrate.title')" v-if="migratorsEnabled">
<x-button <x-button
@ -293,6 +296,7 @@ import AvatarSettings from '../../components/user/avatar-settings.vue'
import copy from 'copy-to-clipboard' import copy from 'copy-to-clipboard'
import ListSearch from '@/components/tasks/partials/listSearch.vue' import ListSearch from '@/components/tasks/partials/listSearch.vue'
import UserSettingsDeletion from '../../components/user/settings/deletion' import UserSettingsDeletion from '../../components/user/settings/deletion'
import DataExport from '../../components/user/settings/data-export'
export default { export default {
name: 'Settings', name: 'Settings',
@ -325,6 +329,7 @@ export default {
UserSettingsDeletion, UserSettingsDeletion,
ListSearch, ListSearch,
AvatarSettings, AvatarSettings,
DataExport,
}, },
created() { created() {
this.passwordUpdateService = new PasswordUpdateService() this.passwordUpdateService = new PasswordUpdateService()