Add attachments to events

Signed-off-by: Mikhail Sazanov <m@sazanof.ru>
This commit is contained in:
Mikhail Sazanov 2023-02-15 18:45:53 +03:00
parent 5cedb44a75
commit 9d964452e5
17 changed files with 1258 additions and 59 deletions

View File

@ -90,6 +90,8 @@ class SettingsController extends Controller {
return $this->setDefaultReminder($value);
case 'showTasks':
return $this->setShowTasks($value);
case 'attachmentsFolder':
return $this->setAttachmentsFolder($value);
default:
return new JSONResponse([], Http::STATUS_BAD_REQUEST);
}
@ -171,6 +173,27 @@ class SettingsController extends Controller {
return new JSONResponse();
}
/**
* Set config for attachments folder
*
* @param string $value
* @return JSONResponse
*/
private function setAttachmentsFolder(string $value):JSONResponse {
try {
$this->config->setUserValue(
$this->userId,
'dav',
'attachmentsFolder',
$value
);
} catch (\Exception $e) {
return new JSONResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse();
}
/**
* set config value for showing week numbers
*

View File

@ -99,6 +99,7 @@ class ViewController extends Controller {
$showWeekNumbers = $this->config->getUserValue($this->userId, $this->appName, 'showWeekNr', $defaultWeekNumbers) === 'yes';
$skipPopover = $this->config->getUserValue($this->userId, $this->appName, 'skipPopover', $defaultSkipPopover) === 'yes';
$timezone = $this->config->getUserValue($this->userId, $this->appName, 'timezone', $defaultTimezone);
$attachmentsFolder = $this->config->getUserValue($this->userId, 'dav', 'attachmentsFolder', '/Calendar');
$slotDuration = $this->config->getUserValue($this->userId, $this->appName, 'slotDuration', $defaultSlotDuration);
$defaultReminder = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminder', $defaultDefaultReminder);
$showTasks = $this->config->getUserValue($this->userId, $this->appName, 'showTasks', $defaultShowTasks) === 'yes';
@ -124,6 +125,7 @@ class ViewController extends Controller {
$this->initialStateService->provideInitialState('talk_enabled', $talkEnabled);
$this->initialStateService->provideInitialState('talk_api_version', $talkApiVersion);
$this->initialStateService->provideInitialState('timezone', $timezone);
$this->initialStateService->provideInitialState('attachments_folder', $attachmentsFolder);
$this->initialStateService->provideInitialState('slot_duration', $slotDuration);
$this->initialStateService->provideInitialState('default_reminder', $defaultReminder);
$this->initialStateService->provideInitialState('show_tasks', $showTasks);

177
package-lock.json generated
View File

@ -48,7 +48,8 @@
"vue-shortkey": "^3.1.7",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0"
"vuex-router-sync": "^5.0.0",
"webdav": "^4.10.0"
},
"devDependencies": {
"@nextcloud/babel-config": "^1.0.0",
@ -5170,8 +5171,12 @@
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"node_modules/base64-js": {
"version": "1.5.1",
@ -5553,6 +5558,11 @@
"dev": true,
"peer": true
},
"node_modules/byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz",
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="
},
"node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -8790,7 +8800,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"bin": {
"he": "bin/he"
}
@ -8840,6 +8849,11 @@
"dev": true,
"peer": true
},
"node_modules/hot-patcher": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-1.0.0.tgz",
"integrity": "sha512-3H8VH0PreeNsKMZw16nTHbUp4YoHCnPlawpsPXGJUR4qENDynl79b6Xk9CIFvLcH1qungBsCuzKcWyzoPPalTw=="
},
"node_modules/hpack.js": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
@ -11519,6 +11533,11 @@
"dev": true,
"peer": true
},
"node_modules/layerr": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/layerr/-/layerr-0.1.2.tgz",
"integrity": "sha512-ob5kTd9H3S4GOG2nVXyQhOu9O8nBgP555XxWPkJI0tR0JeRilfyTp8WtPdIJHLXBmHMSdEq5+KMxiYABeScsIQ=="
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -12053,6 +12072,11 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"peer": true
},
"node_modules/nested-property": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz",
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@ -12589,6 +12613,11 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/path-posix": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz",
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="
},
"node_modules/path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@ -13071,8 +13100,7 @@
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
@ -13484,8 +13512,7 @@
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/resolve": {
"version": "1.22.1",
@ -15344,11 +15371,15 @@
"querystring": "0.2.0"
}
},
"node_modules/url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@ -15863,6 +15894,48 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/webdav": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-4.11.2.tgz",
"integrity": "sha512-Ht9TPD5EB7gYW0YmhRcE5NW0/dn/HQfyLSPQY1Rw1coQ5MQTUooAQ9Bpqt4EU7QLw0b95tX4cU59R+SIojs9KQ==",
"dependencies": {
"axios": "^0.27.2",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"fast-xml-parser": "^3.19.0",
"he": "^1.2.0",
"hot-patcher": "^1.0.0",
"layerr": "^0.1.2",
"md5": "^2.3.0",
"minimatch": "^5.1.0",
"nested-property": "^4.0.0",
"path-posix": "^1.0.0",
"url-join": "^4.0.1",
"url-parse": "^1.5.10"
},
"engines": {
"node": ">=10"
}
},
"node_modules/webdav/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/webdav/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@ -20383,8 +20456,12 @@
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"base64-js": {
"version": "1.5.1",
@ -20700,6 +20777,11 @@
}
}
},
"byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz",
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@ -23208,8 +23290,7 @@
"he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
"hmac-drbg": {
"version": "1.0.1",
@ -23252,6 +23333,11 @@
}
}
},
"hot-patcher": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-1.0.0.tgz",
"integrity": "sha512-3H8VH0PreeNsKMZw16nTHbUp4YoHCnPlawpsPXGJUR4qENDynl79b6Xk9CIFvLcH1qungBsCuzKcWyzoPPalTw=="
},
"hpack.js": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
@ -25191,6 +25277,11 @@
"dev": true,
"peer": true
},
"layerr": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/layerr/-/layerr-0.1.2.tgz",
"integrity": "sha512-ob5kTd9H3S4GOG2nVXyQhOu9O8nBgP555XxWPkJI0tR0JeRilfyTp8WtPdIJHLXBmHMSdEq5+KMxiYABeScsIQ=="
},
"leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@ -25620,6 +25711,11 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"peer": true
},
"nested-property": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz",
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="
},
"node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@ -26030,6 +26126,11 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"path-posix": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz",
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="
},
"path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@ -26375,8 +26476,7 @@
"querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"dev": true
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"queue-microtask": {
"version": "1.2.3",
@ -26701,8 +26801,7 @@
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"resolve": {
"version": "1.22.1",
@ -28141,11 +28240,15 @@
}
}
},
"url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="
},
"url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dev": true,
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
@ -28567,6 +28670,44 @@
"minimalistic-assert": "^1.0.0"
}
},
"webdav": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-4.11.2.tgz",
"integrity": "sha512-Ht9TPD5EB7gYW0YmhRcE5NW0/dn/HQfyLSPQY1Rw1coQ5MQTUooAQ9Bpqt4EU7QLw0b95tX4cU59R+SIojs9KQ==",
"requires": {
"axios": "^0.27.2",
"base-64": "^1.0.0",
"byte-length": "^1.0.2",
"fast-xml-parser": "^3.19.0",
"he": "^1.2.0",
"hot-patcher": "^1.0.0",
"layerr": "^0.1.2",
"md5": "^2.3.0",
"minimatch": "^5.1.0",
"nested-property": "^4.0.0",
"path-posix": "^1.0.0",
"url-join": "^4.0.1",
"url-parse": "^1.5.10"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@ -74,7 +74,8 @@
"vue-shortkey": "^3.1.7",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"vuex-router-sync": "^5.0.0"
"vuex-router-sync": "^5.0.0",
"webdav": "^4.10.0"
},
"browserslist": [
"extends @nextcloud/browserslist-config"

View File

@ -83,6 +83,7 @@
@select="changeDefaultReminder" />
</li>
<SettingsTimezoneSelect :is-disabled="loadingCalendars" />
<SettingsAttachmentsFolder />
<ActionButton @click.prevent.stop="copyPrimaryCalDAV">
<template #icon>
<ClipboardArrowLeftOutline :size="20" decorative />
@ -137,6 +138,7 @@ import {
import SettingsImportSection from './Settings/SettingsImportSection.vue'
import SettingsTimezoneSelect from './Settings/SettingsTimezoneSelect.vue'
import SettingsAttachmentsFolder from './Settings/SettingsAttachmentsFolder.vue'
import { getCurrentUserPrincipal } from '../../services/caldavService.js'
import ShortcutOverview from './Settings/ShortcutOverview.vue'
@ -163,6 +165,7 @@ export default {
Multiselect,
SettingsImportSection,
SettingsTimezoneSelect,
SettingsAttachmentsFolder,
ClipboardArrowLeftOutline,
InformationVariant,
OpenInNewIcon,
@ -200,6 +203,7 @@ export default {
defaultReminder: state => state.settings.defaultReminder,
timezone: state => state.settings.timezone,
locale: (state) => state.settings.momentLocale,
attachmentsFolder: (state) => state.settings.attachmentsFolder,
}),
isBirthdayCalendarDisabled() {
return this.savingBirthdayCalendar || this.loadingCalendars

View File

@ -0,0 +1,96 @@
<!--
- @copyright Copyright (c) 2022 Mikhail Sazanov <m@sazanof.ru>
- @author Mikhail Sazanov <m@sazanof.ru>
-
- @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>
<li class="settings-fieldset-interior-item settings-fieldset-interior-item--folder">
<label for="attachmentsFolder">
{{ $t('calendar', 'Default attachments location') }}
</label>
<div class="form-group">
<NcInputField v-model="attachmentsFolder"
type="text"
@input="debounceSaveAttachmentsFolder(attachmentsFolder)"
@change="debounceSaveAttachmentsFolder(attachmentsFolder)"
@click="selectCalendarFolder"
@focus.once="selectCalendarFolder"
@keyboard.enter="selectCalendarFolder" />
</div>
</li>
</template>
<script>
import debounce from 'debounce'
import { mapState } from 'vuex'
import { getFilePickerBuilder, showError, showSuccess } from '@nextcloud/dialogs'
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
export default {
name: 'SettingsAttachmentsFolder',
components: {
NcInputField,
},
computed: {
...mapState({
attachmentsFolder: state => (state.settings.attachmentsFolder || '/'),
}),
},
methods: {
async selectCalendarFolder() {
const picker = getFilePickerBuilder(t('calendar', 'Select the default location for attachments'))
.setMultiSelect(false)
.setModal(true)
.setType(1)
.addMimeTypeFilter('httpd/unix-directory')
.allowDirectories()
.build()
const path = await picker.pick()
this.saveAttachmentsFolder(path)
},
debounceSaveAttachmentsFolder: debounce(function(...args) {
this.saveAttachmentsFolder(...args)
}, 300),
saveAttachmentsFolder(path) {
if (typeof path !== 'string' || path.trim() === '' || !path.startsWith('/')) {
showError(t('calendar', 'Invalid location selected'))
return
}
if (path.includes('//')) {
path = path.replace(/\/\//gi, '/')
}
this.$store.dispatch('setAttachmentsFolder', { attachmentsFolder: path })
.then(() => {
showSuccess(this.$t('calendar', 'Attachments folder successfully saved.'))
})
.catch((error) => {
console.error(error)
showError(this.$t('calendar', 'Error on saving attachments folder.'))
})
},
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,248 @@
<template>
<div id="attachments">
<input ref="localAttachments"
class="attachments-input"
type="file"
multiple
@change="onLocalAttachmentSelected">
<div class="attachments-summary">
<div class="attachments-summary-inner">
<Paperclip :size="20" />
<div v-if="attachments.length > 0">
{{ n('calendar', '{count} attachment', '{count} attachments', attachments.length, { count: attachments.length }) }}
</div>
<div v-else>
{{ t('calendar', 'No attachments') }}
</div>
</div>
<NcActions>
<template #icon>
<Plus :size="20" />
</template>
<NcActionButton @click="openFilesModal()">
<template #icon>
<Folder :size="20" />
</template>
{{ t('calendar', 'Add from Files') }}
</NcActionButton>
<NcActionButton @click="clickOnUploadButton">
<template #icon>
<Upload :size="20" />
</template>
{{ t('calendar', 'Upload from device') }}
</NcActionButton>
</NcActions>
</div>
<div v-if="attachments.length > 0">
<ul class="attachments-list-item">
<NcListItem v-for="attachment in attachments"
:key="attachment.path"
:force-display-actions="true"
:title="getBaseName(attachment.fileName)"
@click="openFile(attachment.uri)">
<template #icon>
<img :src="getPreview(attachment)" class="attachment-icon">
</template>
<template #actions>
<NcActionButton @click="deleteAttachmentFromEvent(attachment)">
<template #icon>
<Close :size="20" />
</template>
{{ t('calendar', 'Delete file') }}
</NcActionButton>
</template>
</NcListItem>
</ul>
</div>
</div>
</template>
<script>
import {
NcListItem,
NcActions,
NcActionButton,
} from '@nextcloud/vue'
import {
mapState,
} from 'vuex'
import Upload from 'vue-material-design-icons/Upload.vue'
import Close from 'vue-material-design-icons/Close.vue'
import Folder from 'vue-material-design-icons/Folder.vue'
import Paperclip from 'vue-material-design-icons/Paperclip.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import { generateUrl } from '@nextcloud/router'
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
import {
uploadLocalAttachment,
getFileInfo,
} from '../../../services/attachmentService.js'
import { parseXML } from 'webdav/dist/node/tools/dav.js'
export default {
name: 'AttachmentsList',
components: {
NcListItem,
NcActions,
NcActionButton,
Upload,
Close,
Folder,
Paperclip,
Plus,
},
props: {
calendarObjectInstance: {
type: Object,
required: true,
},
isReadOnly: {
type: Boolean,
default: true,
},
},
data() {
return {
uploading: false,
}
},
computed: {
currentUser() {
return this.$store.getters.getCurrentUserPrincipal
},
attachments() {
return this.calendarObjectInstance.attachments
},
...mapState({
attachmentsFolder: state => state.settings.attachmentsFolder,
}),
},
methods: {
addAttachmentWithProperty(calendarObjectInstance, sharedData) {
this.$store.commit('addAttachmentWithProperty', {
calendarObjectInstance,
sharedData,
})
},
deleteAttachmentFromEvent(attachment) {
this.$store.commit('deleteAttachment', {
calendarObjectInstance: this.calendarObjectInstance,
attachment,
})
},
async openFilesModal() {
const picker = getFilePickerBuilder(t('calendar', 'Choose a file to add as attachment')).setMultiSelect(false).build()
try {
const filename = await picker.pick(t('calendar', 'Choose a file to share as a link'))
if (!this.isDuplicateAttachment(filename)) {
// TODO do not share Move this to PHP
const data = await getFileInfo(filename, this.currentUser.dav)
const davRes = await parseXML(data)
const davRespObj = davRes?.multistatus?.response[0]?.propstat?.prop
davRespObj.fileName = filename
davRespObj.url = generateUrl(`/f/${davRespObj.fileid}`)
davRespObj.value = davRespObj.url
this.addAttachmentWithProperty(this.calendarObjectInstance, davRespObj)
}
} catch (error) {
}
},
isDuplicateAttachment(path) {
return this.attachments.find(attachment => {
if (attachment.fileName === path) {
showError(t('calendar', 'Attachment {name} already exist!', { name: this.getBaseName(path) }))
return true
}
return false
})
},
clickOnUploadButton() {
this.$refs.localAttachments.click()
},
async onLocalAttachmentSelected(e) {
const attachments = await uploadLocalAttachment(this.attachmentsFolder, e, this.currentUser.dav, this.attachments)
// TODO do not share file, move to PHP
attachments.map(async attachment => {
const data = await getFileInfo(`${this.attachmentsFolder}/${attachment.path}`, this.currentUser.dav)
const davRes = await parseXML(data)
const davRespObj = davRes?.multistatus?.response[0]?.propstat?.prop
davRespObj.fileName = attachment.path
davRespObj.url = generateUrl(`/f/${davRespObj.fileid}`)
davRespObj.value = davRespObj.url
this.addAttachmentWithProperty(this.calendarObjectInstance, davRespObj)
})
e.target.value = ''
},
getIcon(mime) {
return OC.MimeType.getIconUrl(mime)
},
getPreview(attachment) {
if (attachment.xNcHasPreview) {
return generateUrl(`/core/preview?fileId=${attachment.xNcFileId}&x=100&y=100&a=0`)
}
return attachment.formatType ? OC.MimeType.getIconUrl(attachment.formatType) : null
},
getBaseName(name) {
return name.split('/').pop()
},
openFile(url) {
window.open(url, '_blank', 'noopener noreferrer')
},
},
}
</script>
<style lang="scss" scoped>
.attachments-input {
display: none;
}
.attachments-summary {
display:flex;
align-items: center;
justify-content: space-between;
padding-left: 6px;
.attachments-summary-inner {
display:flex;
align-items: center;
span {
width: 34px;
height: 34px;
margin-left: -10px;
margin-right: 10px;
}
}
}
.attachments-list-item {
margin: 0 -8px;
}
#attachments .empty-content {
margin-top: 1rem;
text-align: center;
}
.button-group {
display: flex;
align-content: center;
justify-content: center;
button:first-child {
margin-right: 6px;
}
}
.attachment-icon {
width: 40px;
height: auto;
border-radius: var(--border-radius);
}
</style>

67
src/models/attachment.js Normal file
View File

@ -0,0 +1,67 @@
/**
* @copyright Copyright (c) 2022 Mikhail Sazanov
*
* @author Mikhail Sazanov <m@sazanof.ru>
*
* @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/>.
*
*/
/**
* Creates a complete attachment object based on given props
*
* @param {object} props The attachment properties already provided
* @return {object}
*/
const getDefaultAttachmentObject = (props = {}) => Object.assign({}, {
// The calendar-js attachment property
attachmentProperty: null,
// The file name of the attachment
fileName: null,
// The attachment mime type
formatType: null,
// The uri of the attachment
uri: null,
// The value from calendar object
value: null,
// Preview of file
xNcHasPreview: null,
// File id in NC
xNcFileId: null,
}, props)
/**
* Maps a calendar-js attachment property to our attachment object
*
* @param {attachmentProperty} attachmentProperty The calendar-js attachmentProperty to turn into a attachment object
* @return {object}
*/
const mapAttachmentPropertyToAttchmentObject = (attachmentProperty) => {
return getDefaultAttachmentObject({
attachmentProperty,
fileName: attachmentProperty.getParameterFirstValue('FILENAME'),
formatType: attachmentProperty.formatType,
uri: attachmentProperty.uri,
value: attachmentProperty.value,
xNcHasPreview: attachmentProperty.getParameterFirstValue('X-NC-HAS-PREVIEW') === 'true',
xNcFileId: attachmentProperty.getParameterFirstValue('X-NC-FILE-ID'),
})
}
export {
getDefaultAttachmentObject,
mapAttachmentPropertyToAttchmentObject,
}

View File

@ -25,6 +25,7 @@ import { DurationValue, DateTimeValue } from '@nextcloud/calendar-js'
import { getHexForColorName, getClosestCSS3ColorNameForHex } from '../utils/color.js'
import { mapAlarmComponentToAlarmObject } from './alarm.js'
import { mapAttendeePropertyToAttendeeObject } from './attendee.js'
import { mapAttachmentPropertyToAttchmentObject } from './attachment.js'
import {
getDefaultRecurrenceRuleObject,
mapRecurrenceRuleValueToRecurrenceRuleObject,
@ -85,6 +86,8 @@ const getDefaultEventObject = (props = {}) => Object.assign({}, {
customColor: null,
// Categories
categories: [],
// Attachments of this event
attachments: [],
}, props)
/**
@ -154,6 +157,14 @@ const mapEventComponentToEventObject = (eventComponent) => {
eventObject.attendees.push(mapAttendeePropertyToAttendeeObject(attendee))
}
/**
* Extract attachments
*/
for (const attachment of eventComponent.getPropertyIterator('ATTACH')) {
eventObject.attachments.push(mapAttachmentPropertyToAttchmentObject(attachment))
}
/**
* Extract recurrence-rule
*/

View File

@ -0,0 +1,173 @@
/**
* @copyright 2022 Mikhail Sazanov <m@sazanof.ru>
*
* @author 2022 Mikhail Sazanov <m@sazanof.ru>
*
* @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 axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
/**
* Makes a share link for a given file or directory.
*
* @param {string} path The file path from the user's root directory. e.g. `/myfile.txt`
* @return {string} url share link
*/
const shareFile = async function(path) {
try {
const res = await axios.post(generateOcsUrl('apps/files_sharing/api/v1/', 2) + 'shares', {
shareType: OC.Share.SHARE_TYPE_LINK,
path,
})
return res.data.ocs.data
} catch (error) {
if (error?.response?.data?.ocs?.meta?.message) {
console.error(`Error while sharing file: ${error.response.data.ocs.meta.message}`)
showError(error.response.data.ocs.meta.message)
throw error
} else {
console.error('Error while sharing file: Unknown error')
showError(t('calendar', 'Error while sharing file'))
throw error
}
}
}
/**
* Share file with a user with permissions
*
* @param path
* @param sharedWith
* @param permissions
* @return {Promise<[{path: string, permissions, scope: string, name: string, backend: string, type: string},{path: string, permissions: *, scope: string, name: string, backend: string, type: string}]>}
*/
const shareFileWith = async function(path, sharedWith, permissions = 17) {
try {
const url = generateOcsUrl('apps/files_sharing/api/v1/', 2)
const res = await axios.post(`${url}shares`, {
password: null,
shareType: OC.Share.SHARE_TYPE_USER, // WITH USERS,
permissions, // 14 - edit, 17 - view
path,
shareWith: sharedWith,
})
return res.data.ocs.data
} catch (error) {
if (error?.response?.data?.ocs?.meta?.message) {
console.error(`Error while sharing file with user: ${error.response.data.ocs.meta.message}`)
showError(error.response.data.ocs.meta.message)
throw error
} else {
console.error('Error while sharing file with user: Unknown error')
showError(t('calendar', 'Error while sharing file with user'))
throw error
}
}
}
const createFolder = async function(folderName, userId) {
const url = `/remote.php/dav/files/${userId}/${folderName}`
await axios({
method: 'MKCOL',
url: url.replace('//', '/'),
}).catch(e => {
if (e.response.status !== 405) {
showError(t('calendar', 'Error creating a folder {folder}', {
folder: folderName,
}))
}
})
}
const uploadLocalAttachment = async function(folder, event, dav, componentAttachments) {
const files = event.target.files
const attachments = []
const promises = []
files.forEach(file => {
// temp fix, until we decide where to save the attachments
if (componentAttachments.map(attachment => attachment.fileName.split('/').pop()).indexOf(file.name) !== -1) {
// TODO may be show user confirmation dialog to create a file named Existing_File_(2) ?
showError(t('calendar', 'Attachment {fileName} already exists!', {
fileName: file.name,
}))
} else {
const url = `/remote.php/dav/files/${dav.userId}/${folder}/${file.name}`
const res = axios.put(url, file).then(resp => {
const data = {
fileName: file.name,
formatType: file.type,
uri: url,
value: url,
path: `/${file.name}`,
}
if (resp.status === 204 || resp.status === 201) {
showSuccess(t('calendar', 'Attachment {fileName} added!', {
fileName: file.name,
}))
attachments.push(data)
}
}).catch(() => {
showError(t('calendar', 'An error occurred during uploading file {fileName}', {
fileName: file.name,
}))
})
promises.push(res)
}
})
await Promise.all(promises)
return attachments
}
// TODO is shared or not @share-types@
const getFileInfo = async function(path, dav) {
const url = `/remote.php/dav/files/${dav.userId}/${path}`
const res = await axios({
method: 'PROPFIND',
url,
data: `<?xml version="1.0"?>
<d:propfind
xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:getcontenttype />
<oc:size />
<oc:fileid />
<oc:share-types />
<nc:has-preview />
</d:prop>
</d:propfind>`,
}).catch(() => {
showError(t('calendar', 'An error occurred during getting file information'))
})
return res.data
}
export {
getFileInfo,
shareFile,
shareFileWith,
uploadLocalAttachment,
createFolder,
}

View File

@ -24,7 +24,7 @@ import getTimezoneManager from '../services/timezoneDataProviderService.js'
import {
getDateFromDateTimeValue,
} from '../utils/date.js'
import { AttendeeProperty, Property, DateTimeValue, DurationValue, RecurValue } from '@nextcloud/calendar-js'
import { AttendeeProperty, Property, DateTimeValue, DurationValue, RecurValue, AttachmentProperty, Parameter } from '@nextcloud/calendar-js'
import { getBySetPositionAndBySetFromDate, getWeekDayFromDate } from '../utils/recurrence.js'
import {
copyCalendarObjectInstanceIntoEventComponent,
@ -47,6 +47,7 @@ import { getObjectAtRecurrenceId } from '../utils/calendarObject.js'
import logger from '../utils/logger.js'
import settings from './settings.js'
import { getRFCProperties } from '../models/rfcProps.js'
import { generateUrl } from '@nextcloud/router'
const state = {
isNew: null,
@ -1356,6 +1357,101 @@ const mutations = {
}
}
},
/**
* @deprecated
* @param state
* @param calendarObjectInstance.calendarObjectInstance
* @param calendarObjectInstance
* @param calendarObjectInstance.sharedData
* @param sharedData
*/
addAttachmentBySharedData(state, { calendarObjectInstance, sharedData }) {
const attachment = AttachmentProperty.fromLink(sharedData.url)
const fileName = sharedData.fileName
// hot-fix needed temporary, becase calendar-js has no fileName get-setter
const parameterFileName = new Parameter('FILENAME', fileName)
// custom has-preview parameter from dav file
const xNcHasPreview = new Parameter('X-NC-HAS-PREVIEW', sharedData['has-preview'].toString())
// custom file id parameter from dav file
const xNcFileId = new Parameter('X-NC-FILE-ID', sharedData.fileid.toString())
// custom share-types parameter from dav file
const xNcSharedTypes = new Parameter('X-NC-SHARED-TYPES', sharedData['share-types']['share-type']
? sharedData['share-types']['share-type'].join(',')
: '')
attachment.setParameter(parameterFileName)
attachment.setParameter(xNcFileId)
attachment.setParameter(xNcHasPreview)
attachment.setParameter(xNcSharedTypes)
attachment.isNew = true
attachment.shareTypes = sharedData['share-types']['share-type']
? sharedData['share-types']['share-type'].join(',')
: ''
attachment.fileName = fileName
attachment.xNcFileId = sharedData.fileid
attachment.xNcHasPreview = sharedData['has-preview']
attachment.formatType = sharedData.getcontenttype
attachment.uri = sharedData.url ? sharedData.url : generateUrl(`/f/${sharedData.fileid}`)
calendarObjectInstance.eventComponent.addProperty(attachment)
calendarObjectInstance.attachments.push(attachment)
// console.log(attachment)
},
addAttachmentWithProperty(state, { calendarObjectInstance, sharedData }) {
const attachment = {}
const fileName = sharedData.fileName
attachment.isNew = true
attachment.shareTypes = (typeof sharedData?.['share-types']?.['share-type'] === 'number'
? sharedData?.['share-types']?.['share-type']?.toString()
: sharedData?.['share-types']?.['share-type']?.join(',')) ?? null
attachment.fileName = fileName
attachment.xNcFileId = sharedData.fileid
attachment.xNcHasPreview = sharedData['has-preview']
attachment.formatType = sharedData.getcontenttype
attachment.uri = sharedData.url ? sharedData.url : generateUrl(`/f/${sharedData.fileid}`)
const attachmentProperty = AttachmentProperty.fromLink(attachment.uri, attachment.formatType)
const parameterFileName = new Parameter('FILENAME', fileName)
const xNcHasPreview = new Parameter('X-NC-HAS-PREVIEW', attachment.xNcHasPreview.toString())
const xNcFileId = new Parameter('X-NC-FILE-ID', attachment.xNcFileId.toString())
// ADD X-NC-SHARED-TYPES only if sharet-type not empty
if (attachment.shareTypes !== null) {
const xNcSharedTypes = new Parameter('X-NC-SHARED-TYPES', attachment.shareTypes)
attachmentProperty.setParameter(xNcSharedTypes)
}
attachmentProperty.setParameter(parameterFileName)
attachmentProperty.setParameter(xNcFileId)
attachmentProperty.setParameter(xNcHasPreview)
attachmentProperty.uri = attachment.uri
attachment.attachmentProperty = attachmentProperty
calendarObjectInstance.eventComponent.addProperty(attachmentProperty)
calendarObjectInstance.attachments.push(attachment)
},
/**
*
* @param {object} state The Vuex state
* @param {object} data The destructuring object
* @param {object} data.calendarObjectInstance The calendarObjectInstance object
* @param {object} data.attachment The attachment object
*/
deleteAttachment(state, { calendarObjectInstance, attachment }) {
try {
const index = calendarObjectInstance.attachments.indexOf(attachment)
if (index !== -1) {
calendarObjectInstance.attachments.splice(index, 1)
}
calendarObjectInstance.eventComponent.removeAttachment(attachment.attachmentProperty)
} catch {
}
},
}
const getters = {}

View File

@ -49,6 +49,7 @@ const state = {
canSubscribeLink: true,
// user-defined Nextcloud settings
momentLocale: 'en',
attachmentsFolder: '/Calendar',
}
const mutations = {
@ -131,6 +132,17 @@ const mutations = {
state.timezone = timezoneId
},
/**
* Updates the user's attachments folder
*
* @param {object} state The Vuex state
* @param {object} data The destructuring object
* @param {string} data.attachmentsFolder The new attachments folder
*/
setAttachmentsFolder(state, { attachmentsFolder }) {
state.attachmentsFolder = attachmentsFolder
},
/**
* Initialize settings
*
@ -152,8 +164,9 @@ const mutations = {
* @param {string} data.forceEventAlarmType
* @param {boolean} data.disableAppointments Allow to disable the appointments feature
* @param {boolean} data.canSubscribeLink
* @param {string} data.attachmentsFolder Default user's attachments folder
*/
loadSettingsFromServer(state, { appVersion, eventLimit, firstRun, showWeekNumbers, showTasks, showWeekends, skipPopover, slotDuration, defaultReminder, talkEnabled, tasksEnabled, timezone, hideEventExport, forceEventAlarmType, disableAppointments, canSubscribeLink }) {
loadSettingsFromServer(state, { appVersion, eventLimit, firstRun, showWeekNumbers, showTasks, showWeekends, skipPopover, slotDuration, defaultReminder, talkEnabled, tasksEnabled, timezone, hideEventExport, forceEventAlarmType, disableAppointments, canSubscribeLink, attachmentsFolder }) {
logInfo(`
Initial settings:
- AppVersion: ${appVersion}
@ -172,6 +185,7 @@ Initial settings:
- ForceEventAlarmType: ${forceEventAlarmType}
- disableAppointments: ${disableAppointments}
- CanSubscribeLink: ${canSubscribeLink}
- attachmentsFolder: ${attachmentsFolder}
`)
state.appVersion = appVersion
@ -190,6 +204,7 @@ Initial settings:
state.forceEventAlarmType = forceEventAlarmType
state.disableAppointments = disableAppointments
state.canSubscribeLink = canSubscribeLink
state.attachmentsFolder = attachmentsFolder
},
/**
@ -410,6 +425,25 @@ const actions = {
commit('setTimezone', { timezoneId })
},
/**
* Updates the user's attachments folder
*
* @param {object} vuex The Vuex destructuring object
* @param {object} vuex.state The Vuex state
* @param {Function} vuex.commit The Vuex commit Function
* @param {object} data The destructuring object
* @param {string} data.attachmentsFolder The new attachments folder
* @return {Promise<void>}
*/
async setAttachmentsFolder({ state, commit }, { attachmentsFolder }) {
if (state.attachmentsFolder === attachmentsFolder) {
return
}
await setConfig('attachmentsFolder', attachmentsFolder)
commit('setAttachmentsFolder', { attachmentsFolder })
},
/**
* Initializes the calendar-js configuration
*

View File

@ -104,6 +104,8 @@ import '@nextcloud/dialogs/styles/toast.scss'
import Trashbin from '../components/AppNavigation/CalendarList/Trashbin.vue'
import AppointmentConfigList from '../components/AppNavigation/AppointmentConfigList.vue'
import { createFolder } from '../services/attachmentService.js'
export default {
name: 'Calendar',
components: {
@ -125,6 +127,7 @@ export default {
data() {
return {
loadingCalendars: true,
loadingUser: true,
timeFrameCacheExpiryJob: null,
showEmptyCalendarScreen: false,
}
@ -133,6 +136,7 @@ export default {
...mapGetters({
timezoneId: 'getResolvedTimezone',
hasTrashBin: 'hasTrashBin',
currentUserPrincipal: 'getCurrentUserPrincipal',
},
),
...mapState({
@ -146,6 +150,7 @@ export default {
timezone: state => state.settings.timezone,
modificationCount: state => state.calendarObjects.modificationCount,
disableAppointments: state => state.settings.disableAppointments,
attachmentsFolder: state => state.settings.attachmentsFolder,
}),
defaultDate() {
return getYYYYMMDDFromFirstdayParam(this.$route.params?.firstDay ?? 'now')
@ -183,6 +188,14 @@ export default {
return null
},
},
watch: {
currentUserPrincipal() {
if (this.currentUserPrincipal !== undefined && this.loadingUser) {
createFolder(this.attachmentsFolder, this.currentUserPrincipal.userId)
this.loadingUser = false
}
},
},
created() {
this.timeFrameCacheExpiryJob = setInterval(() => {
const timestamp = (getUnixTimestampFromDate(dateFactory()) - 60 * 10)
@ -219,6 +232,7 @@ export default {
forceEventAlarmType: loadState('calendar', 'force_event_alarm_type', false),
disableAppointments: loadState('calendar', 'disable_appointments', false),
canSubscribeLink: loadState('calendar', 'can_subscribe_link', false),
attachmentsFolder: loadState('calendar', 'attachments_folder', false),
})
this.$store.dispatch('initializeCalendarJsConfig')

View File

@ -24,7 +24,7 @@
-->
<template>
<AppSidebar :title="title"
<NcAppSidebar :title="title"
:title-editable="!isReadOnly && !isLoading"
:title-placeholder="$t('calendar', 'Event title')"
:subtitle="subTitle"
@ -37,13 +37,12 @@
<div class="icon icon-loading app-sidebar-tab-loading-indicator__icon" />
</div>
</template>
<template v-else-if="isError">
<EmptyContent :title="$t('calendar', 'Event does not exist')" :description="error">
<NcEmptyContent :title="$t('calendar', 'Event does not exist')" :description="error">
<template #icon>
<CalendarBlank :size="20" decorative />
</template>
</EmptyContent>
</NcEmptyContent>
</template>
<template #header>
@ -52,37 +51,37 @@
<template v-if="!isLoading && !isError && !isNew"
#secondary-actions>
<ActionLink v-if="!hideEventExport && hasDownloadURL"
<NcActionLink v-if="!hideEventExport && hasDownloadURL"
:href="downloadURL">
<template #icon>
<Download :size="20" decorative />
</template>
{{ $t('calendar', 'Export') }}
</ActionLink>
<ActionButton v-if="!canCreateRecurrenceException && !isReadOnly" @click="duplicateEvent()">
</NcActionLink>
<NcActionButton v-if="!canCreateRecurrenceException && !isReadOnly" @click="duplicateEvent()">
<template #icon>
<ContentDuplicate :size="20" decorative />
</template>
{{ $t('calendar', 'Duplicate') }}
</ActionButton>
<ActionButton v-if="canDelete && !canCreateRecurrenceException" @click="deleteAndLeave(false)">
</NcActionButton>
<NcActionButton v-if="canDelete && !canCreateRecurrenceException" @click="deleteAndLeave(false)">
<template #icon>
<Delete :size="20" decorative />
</template>
{{ $t('calendar', 'Delete') }}
</ActionButton>
<ActionButton v-if="canDelete && canCreateRecurrenceException" @click="deleteAndLeave(false)">
</NcActionButton>
<NcActionButton v-if="canDelete && canCreateRecurrenceException" @click="deleteAndLeave(false)">
<template #icon>
<Delete :size="20" decorative />
</template>
{{ $t('calendar', 'Delete this occurrence') }}
</ActionButton>
<ActionButton v-if="canDelete && canCreateRecurrenceException" @click="deleteAndLeave(true)">
</NcActionButton>
<NcActionButton v-if="canDelete && canCreateRecurrenceException" @click="deleteAndLeave(true)">
<template #icon>
<Delete :size="20" decorative />
</template>
{{ $t('calendar', 'Delete this and all future') }}
</ActionButton>
</NcActionButton>
</template>
<template v-if="!isLoading && !isError"
@ -115,7 +114,7 @@
@close="closeEditorAndSkipAction" />
</template>
<AppSidebarTab v-if="!isLoading && !isError"
<NcAppSidebarTab v-if="!isLoading && !isError"
id="app-sidebar-tab-details"
class="app-sidebar-tab"
:name="$t('calendar', 'Details')"
@ -170,16 +169,69 @@
:is-editing-master-item="isEditingMasterItem"
:is-recurrence-exception="isRecurrenceException"
@force-this-and-all-future="forceModifyingFuture" />
<AttachmentsList v-if="!isLoading"
:calendar-object-instance="calendarObjectInstance"
:is-read-only="isReadOnly" />
<NcModal v-if="showModal && !isPrivate()"
:title="t('calendar', 'Managing shared access')"
@close="closeAttachmentsModal">
<div class="modal-content">
<div v-if="showPreloader" class="modal-content-preloader">
<div :style="`width:${sharedProgress}%`" />
</div>
<div class="modal-h">
{{ n('calendar', 'User requires access to your file', 'Users requires access to your file', showModalUsers.length) }}
</div>
<div class="users">
<NcListItemIcon v-for="attendee in showModalUsers"
:key="attendee.uri"
class="user-list-item"
:title="attendee.commonName"
:subtitle="emailWithoutMailto(attendee.uri)"
:is-no-user="true" />
</div>
<div class="modal-subtitle">
{{ n('calendar', 'Attachment requiring shared access', 'Attachments requiring shared access', showModalNewAttachments.length) }}
</div>
<div class="attachments">
<NcListItemIcon v-for="attachment in showModalNewAttachments"
:key="attachment.xNcFileId"
class="attachment-list-item"
:title="getBaseName(attachment.fileName)"
:url="getPreview(attachment)"
:force-display-actions="false" />
</div>
<div class="modal-footer">
<div class="modal-footer-checkbox">
<NcCheckboxRadioSwitch v-if="!isPrivate()" :checked.sync="doNotShare">
{{ t('calendar', 'Deny access') }}
</NcCheckboxRadioSwitch>
</div>
<div class="modal-footer-buttons">
<NcButton @click="closeAttachmentsModal">
{{ t('calendar', 'Cancel') }}
</NcButton>
<NcButton type="primary"
:disabled="showPreloader"
@click="acceptAttachmentsModal(thisAndAllFuture)">
{{ t('calendar', 'Invite') }}
</NcButton>
</div>
</div>
</div>
</NcModal>
</div>
<SaveButtons v-if="showSaveButtons"
class="app-sidebar-tab__buttons"
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:force-this-and-all-future="forceThisAndAllFuture"
@save-this-only="saveAndLeave(false)"
@save-this-and-all-future="saveAndLeave(true)" />
</AppSidebarTab>
<AppSidebarTab v-if="!isLoading && !isError"
@save-this-only="prepareAccessForAttachments(false)"
@save-this-and-all-future="prepareAccessForAttachments(true)" />
</NcAppSidebarTab>
<NcAppSidebarTab v-if="!isLoading && !isError"
id="app-sidebar-tab-attendees"
class="app-sidebar-tab"
:name="$t('calendar', 'Attendees')"
@ -197,14 +249,14 @@
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:force-this-and-all-future="forceThisAndAllFuture"
@save-this-only="saveAndLeave(false)"
@save-this-and-all-future="saveAndLeave(true)" />
</AppSidebarTab>
<AppSidebarTab v-if="!isLoading && !isError"
@save-this-only="prepareAccessForAttachments(false)"
@save-this-and-all-future="prepareAccessForAttachments(true)" />
</NcAppSidebarTab>
<NcAppSidebarTab v-if="!isLoading && !isError"
id="app-sidebar-tab-resources"
class="app-sidebar-tab"
:name="$t('calendar', 'Resources')"
:order="2">
:order="3">
<template #icon>
<MapMarker :size="20" decorative />
</template>
@ -218,19 +270,27 @@
:can-create-recurrence-exception="canCreateRecurrenceException"
:is-new="isNew"
:force-this-and-all-future="forceThisAndAllFuture"
@save-this-only="saveAndLeave(false)"
@save-this-and-all-future="saveAndLeave(true)" />
</AppSidebarTab>
</AppSidebar>
@save-this-only="prepareAccessForAttachments(false)"
@save-this-and-all-future="prepareAccessForAttachments(true)" />
</NcAppSidebarTab>
</NcAppSidebar>
</template>
<script>
import AppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
import AppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
import ActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
import ActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import EmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import {
NcAppSidebar,
NcAppSidebarTab,
NcActionLink,
NcActionButton,
NcEmptyContent,
NcModal,
NcListItemIcon,
NcButton,
NcCheckboxRadioSwitch,
} from '@nextcloud/vue'
import { mapState } from 'vuex'
import { generateUrl } from '@nextcloud/router'
import AlarmList from '../components/Editor/Alarm/AlarmList.vue'
@ -249,6 +309,7 @@ import PropertySelectMultiple from '../components/Editor/Properties/PropertySele
import PropertyColor from '../components/Editor/Properties/PropertyColor.vue'
import ResourceList from '../components/Editor/Resources/ResourceList.vue'
import InvitationResponseButtons from '../components/Editor/InvitationResponseButtons.vue'
import AttachmentsList from '../components/Editor/Attachments/AttachmentsList.vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import CalendarBlank from 'vue-material-design-icons/CalendarBlank.vue'
@ -258,6 +319,9 @@ import ContentDuplicate from 'vue-material-design-icons/ContentDuplicate.vue'
import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
import MapMarker from 'vue-material-design-icons/MapMarker.vue'
import { shareFile } from '../services/attachmentService.js'
import { Parameter } from '@nextcloud/calendar-js'
export default {
name: 'EditSidebar',
components: {
@ -267,11 +331,15 @@ export default {
SaveButtons,
IllustrationHeader,
AlarmList,
AppSidebar,
AppSidebarTab,
ActionLink,
ActionButton,
EmptyContent,
NcAppSidebar,
NcAppSidebarTab,
NcActionLink,
NcActionButton,
NcEmptyContent,
NcModal,
NcListItemIcon,
NcButton,
NcCheckboxRadioSwitch,
InviteesList,
PropertyCalendarPicker,
PropertySelect,
@ -286,14 +354,27 @@ export default {
InformationOutline,
MapMarker,
InvitationResponseButtons,
AttachmentsList,
},
mixins: [
EditorMixin,
],
data() {
return {
thisAndAllFuture: false,
doNotShare: false,
showModal: false,
showModalNewAttachments: [],
showModalUsers: [],
sharedProgress: 0,
showPreloader: false,
}
},
computed: {
...mapState({
locale: (state) => state.settings.momentLocale,
hideEventExport: (state) => state.settings.hideEventExport,
attachmentsFolder: state => state.settings.attachmentsFolder,
}),
accessClass() {
return this.calendarObjectInstance?.accessClass || null
@ -314,6 +395,12 @@ export default {
return moment(this.calendarObjectInstance.startDate).locale(this.locale).fromNow()
},
attachments() {
return this.calendarObjectInstance?.attachments || null
},
currentUser() {
return this.$store.getters.getCurrentUserPrincipal || null
},
/**
* @return {boolean}
*/
@ -405,11 +492,189 @@ export default {
customColor,
})
},
/**
* Checks is the calendar event has attendees, but organizer or not
*
* @return {boolean}
*/
isPrivate() {
return this.calendarObjectInstance.attendees.filter((attendee) => {
if (this.currentUser.emailAddress.toLowerCase() !== (
attendee.uri.split('mailto:').length === 2
? attendee.uri.split('mailto:')[1].toLowerCase()
: attendee.uri.toLowerCase()
)) {
return attendee
}
return false
}).length === 0
},
getPreview(attachment) {
if (attachment.xNcHasPreview) {
return generateUrl(`/core/preview?fileId=${attachment.xNcFileId}&x=100&y=100&a=0`)
}
return attachment.formatType ? OC.MimeType.getIconUrl(attachment.formatType) : null
},
acceptAttachmentsModal() {
if (!this.doNotShare) {
const total = this.showModalNewAttachments.length
this.showPreloader = true
if (!this.isPrivate()) {
this.showModalNewAttachments.map(async (attachment, i) => {
// console.log('Add share', attachment)
this.sharedProgress = Math.ceil(100 * (i + 1) / total)
// add share + change attachment
try {
const data = await shareFile(`${this.attachmentsFolder}${attachment.fileName}`)
attachment.shareTypes = data?.share_type?.toString()
if (typeof attachment.attachmentProperty.getParameter('X-NC-SHARED-TYPES') === 'undefined') {
const xNcSharedTypes = new Parameter('X-NC-SHARED-TYPES', attachment.shareTypes)
attachment.attachmentProperty.setParameter(xNcSharedTypes)
}
attachment.attachmentProperty.uri = data?.url
attachment.uri = data?.url
// toastify success
} catch (e) {
// toastify err
console.error(e)
}
return attachment
})
} else {
// TODO it is not possible to delete shares, because share ID needed
/* this.showModalNewAttachments.map((attachment, i) => {
this.sharedProgress += Math.ceil(100 * (i + 1) / total)
return attachment
}) */
}
}
setTimeout(() => {
this.showPreloader = false
this.sharedProgress = 0
this.showModal = false
this.showModalNewAttachments = []
this.showModalUsers = []
this.saveEvent(this.thisAndAllFuture)
}, 500)
// trigger save event after make each attachment access
// 1) if !isPrivate get attachments NOT SHARED and SharedType is empry -> API ADD SHARE
// 2) if isPrivate get attachments SHARED and SharedType is not empty -> API DELETE SHARE
// 3) update calendarObject while pending access change
// 4) after all access changes, save Event trigger
// 5) done
},
closeAttachmentsModal() {
this.showModal = false
},
emailWithoutMailto(mailto) {
return mailto.split('mailto:').length === 2
? mailto.split('mailto:')[1].toLowerCase()
: mailto.toLowerCase()
},
getBaseName(name) {
return name.split('/').pop()
},
prepareAccessForAttachments(thisAndAllFuture = false) {
this.thisAndAllFuture = thisAndAllFuture
const newAttachments = this.calendarObjectInstance.attachments.filter(attachment => {
// get only new attachments
// TODO get NOT only new attachments =) Maybe we should filter all attachments without share-type, 'cause event can be private and AFTER save owner could add new participant
return !this.isPrivate() ? attachment.isNew && attachment.shareTypes === null : attachment.isNew && attachment.shareTypes !== null
})
// if there are new attachment and event not saved
if (newAttachments.length > 0 && !this.isPrivate()) {
// and is event NOT private,
// then add share to each attachment
// only if attachment['share-types'] is null or empty
this.showModal = true
this.showModalNewAttachments = newAttachments
this.showModalUsers = this.calendarObjectInstance.attendees.filter((attendee) => {
if (this.currentUser.emailAddress.toLowerCase() !== this.emailWithoutMailto(attendee.uri)) {
return attendee
}
return false
})
} else {
this.saveEvent(thisAndAllFuture)
}
},
saveEvent(thisAndAllFuture = false) {
// if there is new attachments and !private, then make modal with users and files/
// maybe check shared access before add file
this.saveAndLeave(thisAndAllFuture)
this.calendarObjectInstance.attachments = this.calendarObjectInstance.attachments.map(attachment => {
if (attachment.isNew) {
delete attachment.isNew
}
return attachment
})
},
},
}
</script>
<style lang="scss" scoped>
.modal-content {
padding: 16px;
position: relative;
.modal-content-preloader {
position: absolute;
top:0;
left:0;
right:0;
height: 6px;
div {
position: absolute;
top:0;
left: 0;
background: var(--color-primary-element);
height: 6px;
transition: width 0.3s linear;
}
}
}
.modal-subtitle {
font-weight: bold;
font-size: 16px;
margin-top: 16px;
}
.modal-h {
font-size: 24px;
font-weight: bold;
margin: 10px 0;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
.modal-footer-buttons {
display: flex;
:first-child {
margin-right: 6px;
}
}
}
.attachments, .users {
display: flex;
flex-wrap: wrap;
}
::v-deep .attachments .avatardiv img {
border-radius: 0;
}
.attachment-list-item, .user-list-item {
width: 50%
}
.attachment-icon {
width: 40px;
height: auto;
border-radius: var(--border-radius);
}
::v-deep .app-sidebar-header__description {
flex-direction: column;
}

View File

@ -78,6 +78,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDefaultRecurrenceRuleObject).toHaveBeenCalledTimes(1)
@ -119,6 +120,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
otherProp: 'foo',
})
@ -168,6 +170,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -235,6 +238,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: []
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -301,6 +305,7 @@ describe('Test suite: Event model (models/event.js)', () => {
],
customColor: null,
categories: [],
attachments: [],
})
const alarms = eventComponent.getAlarmList()
@ -354,6 +359,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: ['BUSINESS', 'HUMAN RESOURCES'],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -409,6 +415,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: '#eeffee',
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -467,6 +474,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -522,6 +530,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -574,6 +583,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -626,6 +636,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -682,6 +693,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -737,6 +749,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -790,6 +803,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)
@ -846,6 +860,7 @@ describe('Test suite: Event model (models/event.js)', () => {
alarms: [],
customColor: null,
categories: [],
attachments: [],
})
expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2)

View File

@ -65,6 +65,7 @@ describe('store/settings test suite', () => {
momentLocale: 'en',
disableAppointments: false,
canSubscribeLink: true,
attachmentsFolder: '/Calendar',
})
})
@ -175,6 +176,7 @@ describe('store/settings test suite', () => {
forceEventAlarmType: false,
disableAppointments: false,
canSubscribeLink: true,
attachmentsFolder: '/Calendar',
}
const settings = {
@ -195,6 +197,7 @@ describe('store/settings test suite', () => {
forceEventAlarmType: false,
disableAppointments: false,
canSubscribeLink: true,
attachmentsFolder: '/Attachments',
}
settingsStore.mutations.loadSettingsFromServer(state, settings)
@ -218,6 +221,7 @@ Initial settings:
- ForceEventAlarmType: false
- disableAppointments: false
- CanSubscribeLink: true
- attachmentsFolder: /Attachments
`)
expect(state).toEqual({
appVersion: '2.1.0',
@ -238,6 +242,7 @@ Initial settings:
forceEventAlarmType: false,
disableAppointments: false,
canSubscribeLink: true,
attachmentsFolder: '/Attachments',
})
})

View File

@ -113,6 +113,7 @@ class ViewControllerTest extends TestCase {
['user123', 'calendar', 'showWeekNr', 'defaultShowWeekNr', 'yes'],
['user123', 'calendar', 'skipPopover', 'defaultSkipPopover', 'yes'],
['user123', 'calendar', 'timezone', 'defaultTimezone', 'Europe/Berlin'],
['user123', 'dav', 'attachmentsFolder', '/Calendar', '/Calendar'],
['user123', 'calendar', 'slotDuration', 'defaultSlotDuration', '00:15:00'],
['user123', 'calendar', 'defaultReminder', 'defaultDefaultReminder', '00:10:00'],
['user123', 'calendar', 'showTasks', 'defaultShowTasks', '00:15:00'],
@ -146,6 +147,7 @@ class ViewControllerTest extends TestCase {
['talk_enabled', true],
['talk_api_version', 'v4'],
['timezone', 'Europe/Berlin'],
['attachments_folder', '/Calendar'],
['slot_duration', '00:15:00'],
['default_reminder', '00:10:00'],
['show_tasks', false],
@ -194,6 +196,7 @@ class ViewControllerTest extends TestCase {
['user123', 'calendar', 'showWeekNr', 'defaultShowWeekNr', 'yes'],
['user123', 'calendar', 'skipPopover', 'defaultSkipPopover', 'yes'],
['user123', 'calendar', 'timezone', 'defaultTimezone', 'Europe/Berlin'],
['user123', 'dav', 'attachmentsFolder', '/Calendar', '/Calendar'],
['user123', 'calendar', 'slotDuration', 'defaultSlotDuration', '00:15:00'],
['user123', 'calendar', 'defaultReminder', 'defaultDefaultReminder', '00:10:00'],
['user123', 'calendar', 'showTasks', 'defaultShowTasks', '00:15:00'],
@ -223,6 +226,7 @@ class ViewControllerTest extends TestCase {
['talk_enabled', false],
['talk_api_version', 'v1'],
['timezone', 'Europe/Berlin'],
['attachments_folder', '/Calendar'],
['slot_duration', '00:15:00'],
['default_reminder', '00:10:00'],
['show_tasks', false],