diff --git a/public/images/migration/vikunja-file.png b/public/images/migration/vikunja-file.png new file mode 100644 index 000000000..ee9eb1583 Binary files /dev/null and b/public/images/migration/vikunja-file.png differ diff --git a/src/components/migrator/migration.vue b/src/components/migrator/migration.vue index d24c08bdf..35a613f11 100644 --- a/src/components/migrator/migration.vue +++ b/src/components/migrator/migration.vue @@ -1,16 +1,34 @@ diff --git a/src/components/tasks/partials/attachments.vue b/src/components/tasks/partials/attachments.vue index d49409f21..aa93494f7 100644 --- a/src/components/tasks/partials/attachments.vue +++ b/src/components/tasks/partials/attachments.vue @@ -57,7 +57,7 @@ @click.prevent.stop="downloadAttachment(a)" v-tooltip="$t('task.attachment.downloadTooltip')" > - {{ $t('task.attachment.download') }} + {{ $t('misc.download') }} + +

+ {{ $t('user.export.description') }} +

+

+ {{ $t('user.export.descriptionPasswordRequired') }} +

+
+ +
+ +
+

+ {{ $t('user.deletion.passwordRequired') }} +

+
+ + + {{ $t('user.export.request') }} + +
+ + + diff --git a/src/helpers/downloadBlob.ts b/src/helpers/downloadBlob.ts new file mode 100644 index 000000000..94adcbfe8 --- /dev/null +++ b/src/helpers/downloadBlob.ts @@ -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) +} \ No newline at end of file diff --git a/src/helpers/migrator.ts b/src/helpers/migrator.ts new file mode 100644 index 000000000..8320249db --- /dev/null +++ b/src/helpers/migrator.ts @@ -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) + } +} \ No newline at end of file diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 0fcf94f3c..297888659 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -111,6 +111,13 @@ "scheduledCancelText": "To cancel the deletion of your account, please enter your password below:", "scheduledCancelConfirm": "Cancel the deletion of my 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": { @@ -371,7 +378,9 @@ "inProgress": "Importing in progress…", "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?", - "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": { "title": "Labels", @@ -432,7 +441,8 @@ "saving": "Saving…", "saved": "Saved!", "default": "Default", - "close": "Close" + "close": "Close", + "download": "Download" }, "input": { "resetColor": "Reset Color", @@ -563,7 +573,6 @@ "attachment": { "title": "Attachments", "createdBy": "created {0} by {1}", - "download": "Download", "downloadTooltip": "Download this attachment", "upload": "Upload attachment", "drop": "Drop files here to upload", diff --git a/src/router/index.js b/src/router/index.js index 493d0a5e7..a485ab801 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -10,6 +10,7 @@ import About from '../views/About' import LoginComponent from '../views/user/Login' import RegisterComponent from '../views/user/Register' import OpenIdAuth from '../views/user/OpenIdAuth' +import DataExportDownload from '../views/user/DataExportDownload' // Tasks import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange' import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth' @@ -149,6 +150,11 @@ export default new Router({ name: 'user.settings', component: UserSettingsComponent, }, + { + path: '/user/export/download', + name: 'user.export.download', + component: DataExportDownload, + }, { path: '/share/:share/auth', name: 'link-share.auth', diff --git a/src/services/abstractService.js b/src/services/abstractService.js index 7855421c9..50284b16d 100644 --- a/src/services/abstractService.js +++ b/src/services/abstractService.js @@ -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. * 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} */ uploadFormData(url, formData) { + console.log(formData, formData._boundary) + const cancel = this.setLoading() return this.http.put( url, diff --git a/src/services/attachment.js b/src/services/attachment.js index b0fa38ba4..afd28cdc4 100644 --- a/src/services/attachment.js +++ b/src/services/attachment.js @@ -1,6 +1,7 @@ import AbstractService from './abstractService' import AttachmentModel from '../models/attachment' import {formatISO} from 'date-fns' +import {downloadBlob} from '@/helpers/downloadBlob' export default class AttachmentService extends AbstractService { constructor() { @@ -33,23 +34,12 @@ export default class AttachmentService extends AbstractService { } getBlobUrl(model) { - return this.http({ - url: '/tasks/' + model.taskId + '/attachments/' + model.id, - method: 'GET', - responseType: 'blob', - }).then(response => { - return window.URL.createObjectURL(new Blob([response.data])) - }) + return AbstractService.prototype.getBlobUrl.call(this, '/tasks/' + model.taskId + '/attachments/' + model.id) } download(model) { - this.getBlobUrl(model).then(url => { - const link = document.createElement('a') - link.href = url - link.setAttribute('download', model.file.name) - link.click() - window.URL.revokeObjectURL(url) - }) + this.getBlobUrl(model) + .then(url => downloadBlob(url, model.file.name)) } /** diff --git a/src/services/dataExport.js b/src/services/dataExport.js new file mode 100644 index 000000000..22b436935 --- /dev/null +++ b/src/services/dataExport.js @@ -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')) + } +} \ No newline at end of file diff --git a/src/services/migrator/abstractMigrationService.js b/src/services/migrator/abstractMigration.js similarity index 100% rename from src/services/migrator/abstractMigrationService.js rename to src/services/migrator/abstractMigration.js diff --git a/src/services/migrator/abstractMigrationFile.js b/src/services/migrator/abstractMigrationFile.js new file mode 100644 index 000000000..dba209377 --- /dev/null +++ b/src/services/migrator/abstractMigrationFile.js @@ -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', + ) + } +} diff --git a/src/views/migrator/Migrate.vue b/src/views/migrator/Migrate.vue index 32b80adaa..adab41fb2 100644 --- a/src/views/migrator/Migrate.vue +++ b/src/views/migrator/Migrate.vue @@ -3,15 +3,20 @@

{{ $t('migrate.title') }}

{{ $t('migrate.description') }}

- - - {{ m }} + + + {{ m.name }}
diff --git a/src/views/user/Settings.vue b/src/views/user/Settings.vue index a66ac6499..58c8ca636 100644 --- a/src/views/user/Settings.vue +++ b/src/views/user/Settings.vue @@ -235,6 +235,9 @@ + + + @@ -293,6 +296,7 @@ import AvatarSettings from '../../components/user/avatar-settings.vue' import copy from 'copy-to-clipboard' import ListSearch from '@/components/tasks/partials/listSearch.vue' import UserSettingsDeletion from '../../components/user/settings/deletion' +import DataExport from '../../components/user/settings/data-export' export default { name: 'Settings', @@ -325,6 +329,7 @@ export default { UserSettingsDeletion, ListSearch, AvatarSettings, + DataExport, }, created() { this.passwordUpdateService = new PasswordUpdateService()