diff --git a/.gitignore b/.gitignore index 4ae24a65..52f47104 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ log.txt .vscode/* npm-debug.log* +# This file is sometimes created automatically, even though +# we actually use the capacitor.config.ts +capacitor.config.json + .idea/ .ionic/ .sourcemaps/ diff --git a/PITFALLS.md b/PITFALLS.md index 6dbb6376..ead34134 100644 --- a/PITFALLS.md +++ b/PITFALLS.md @@ -18,6 +18,20 @@ in line `var distributionUrl = process.env['CORDOVA_ANDROID_GRADLE_DISTRIBUTION_ 'https\\://services.gradle.org/distributions/gradle-4.1-all.zip';` * Repeat this for file `StudioBuilder.js` +#### Problem + +`android.support... not found` on build + +#### Solution + +``` +npm install jetifier +npx jetify +npx cap sync android +``` + +[more here](https://stackoverflow.com/questions/62195760/ionic-capacitor-build-cannot-find-symbol-android-support-v4-app-activitycompat) + ### Run platform iOS #### Problem diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 9f7cc30b..0fea3155 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -12,11 +12,17 @@ dependencies { implementation project(':capacitor-community-http') implementation project(':capacitor-app') implementation project(':capacitor-browser') + implementation project(':capacitor-device') + implementation project(':capacitor-dialog') + implementation project(':capacitor-filesystem') implementation project(':capacitor-haptics') implementation project(':capacitor-keyboard') + implementation project(':capacitor-local-notifications') + implementation project(':capacitor-share') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') implementation project(':capacitor-storage') + implementation project(':transistorsoft-capacitor-background-fetch') implementation project(':capacitor-secure-storage-plugin') implementation "androidx.legacy:legacy-support-v4:1.0.0" implementation "androidx.appcompat:appcompat:1.3.1" diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json index eaf6c883..bba02011 100644 --- a/android/app/src/main/assets/capacitor.config.json +++ b/android/app/src/main/assets/capacitor.config.json @@ -15,5 +15,8 @@ "SplashScreen": "screen", "SplashScreenDelay": "3000" } + }, + "plugins": { + "LocalNotifications": {} } } diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index 27de66f3..8ec772ee 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -11,6 +11,18 @@ "pkg": "@capacitor/browser", "classpath": "com.capacitorjs.plugins.browser.BrowserPlugin" }, + { + "pkg": "@capacitor/device", + "classpath": "com.capacitorjs.plugins.device.DevicePlugin" + }, + { + "pkg": "@capacitor/dialog", + "classpath": "com.capacitorjs.plugins.dialog.DialogPlugin" + }, + { + "pkg": "@capacitor/filesystem", + "classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" + }, { "pkg": "@capacitor/haptics", "classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin" @@ -19,6 +31,14 @@ "pkg": "@capacitor/keyboard", "classpath": "com.capacitorjs.plugins.keyboard.KeyboardPlugin" }, + { + "pkg": "@capacitor/local-notifications", + "classpath": "com.capacitorjs.plugins.localnotifications.LocalNotificationsPlugin" + }, + { + "pkg": "@capacitor/share", + "classpath": "com.capacitorjs.plugins.share.SharePlugin" + }, { "pkg": "@capacitor/splash-screen", "classpath": "com.capacitorjs.plugins.splashscreen.SplashScreenPlugin" @@ -31,6 +51,10 @@ "pkg": "@capacitor/storage", "classpath": "com.capacitorjs.plugins.storage.StoragePlugin" }, + { + "pkg": "@transistorsoft/capacitor-background-fetch", + "classpath": "com.transistorsoft.bgfetch.capacitor.BackgroundFetchPlugin" + }, { "pkg": "capacitor-secure-storage-plugin", "classpath": "com.whitestein.securestorage.SecureStoragePlugin" diff --git a/android/app/src/main/res/xml/config.xml b/android/app/src/main/res/xml/config.xml index e9b4d4aa..1131b5fb 100644 --- a/android/app/src/main/res/xml/config.xml +++ b/android/app/src/main/res/xml/config.xml @@ -2,6 +2,10 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle index fede5697..565f6e48 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -21,6 +21,10 @@ allprojects { repositories { google() jcenter() + // https://github.com/transistorsoft/capacitor-background-fetch/blob/master/example/android/build.gradle + maven { + url("${project(':transistorsoft-capacitor-background-fetch').projectDir}/libs") + } } } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index e1789033..4a8c94c7 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -11,12 +11,27 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/ include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') +include ':capacitor-device' +project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android') + +include ':capacitor-dialog' +project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') + +include ':capacitor-filesystem' +project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + include ':capacitor-haptics' project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') include ':capacitor-keyboard' project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android') +include ':capacitor-local-notifications' +project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') + +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android') + include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android') @@ -26,5 +41,8 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit include ':capacitor-storage' project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android') +include ':transistorsoft-capacitor-background-fetch' +project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../node_modules/@transistorsoft/capacitor-background-fetch/android') + include ':capacitor-secure-storage-plugin' project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index 9d56b97a..f5cbc384 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -18,6 +18,11 @@ const config: CapacitorConfig = { 'SplashScreenDelay': '3000', }, }, + plugins: { + LocalNotifications: { + // TODO + }, + }, }; export default config; diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..e69de29b diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 3d2cf369..6aefbcbc 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -366,7 +366,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 8C99SX84P9; + DEVELOPMENT_TEAM = QN788YUV45; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -388,7 +388,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 8C99SX84P9; + DEVELOPMENT_TEAM = QN788YUV45; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 240b40ca..fb308c73 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -2,6 +2,8 @@ + NSCalendarsUsageDescription + App uses calendar for schedule sync CFBundleDevelopmentRegion en CFBundleDisplayName diff --git a/ios/App/App/capacitor.config.json b/ios/App/App/capacitor.config.json index eaf6c883..f94e52cf 100644 --- a/ios/App/App/capacitor.config.json +++ b/ios/App/App/capacitor.config.json @@ -15,5 +15,11 @@ "SplashScreen": "screen", "SplashScreenDelay": "3000" } + }, + "plugins": { + "LocalNotifications": {} + }, + "server": { + "url": "http://141.2.95.77:8100" } } diff --git a/ios/App/App/config.xml b/ios/App/App/config.xml index 404a2494..35ebaaf2 100644 --- a/ios/App/App/config.xml +++ b/ios/App/App/config.xml @@ -2,6 +2,10 @@ + + + + diff --git a/ios/App/Podfile b/ios/App/Podfile index 679c990c..fd345cf2 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -12,11 +12,17 @@ def capacitor_pods pod 'CapacitorCommunityHttp', :path => '../../node_modules/@capacitor-community/http' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser' + pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device' + pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog' + pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem' pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics' pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' + pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' + pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' pod 'CapacitorStorage', :path => '../../node_modules/@capacitor/storage' + pod 'TransistorsoftCapacitorBackgroundFetch', :path => '../../node_modules/@transistorsoft/capacitor-background-fetch' pod 'CapacitorSecureStoragePlugin', :path => '../../node_modules/capacitor-secure-storage-plugin' pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' pod 'CordovaPluginsResources', :path => '../capacitor-cordova-ios-plugins' diff --git a/package-lock.json b/package-lock.json index 6a08ce32..eb01c446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -703,6 +703,22 @@ "resolved": "https://registry.npmjs.org/@asymmetrik/ngx-leaflet-markercluster/-/ngx-leaflet-markercluster-5.0.1.tgz", "integrity": "sha512-XqRSgMKZN/670/nRXtjZ98fniuCzvQnZ5EDEOr+coEC4OI0OYeDIvAN253C5U8kJX6hIdzDtQcbOrC1milWUBw==" }, + "@awesome-cordova-plugins/calendar": { + "version": "5.37.1", + "resolved": "https://registry.npmjs.org/@awesome-cordova-plugins/calendar/-/calendar-5.37.1.tgz", + "integrity": "sha512-sYnUXNC+sAcIm5RHP2Z0rmf0NA9ZhVxe4yr0dZ14jpzJfyrfGm6gTbKIBFjaZCBpMDSM2aofdHtzJ1Ps4hD7mw==", + "requires": { + "@types/cordova": "^0.0.34" + } + }, + "@awesome-cordova-plugins/core": { + "version": "5.37.1", + "resolved": "https://registry.npmjs.org/@awesome-cordova-plugins/core/-/core-5.37.1.tgz", + "integrity": "sha512-7ySdolkR27NQ55pSA75TEwCbgVJL0EL0EBVcWfhwqfE5gAFt8vycGiyCrhBN9/ijFKxi/7NFHZEosX/WGNMQlA==", + "requires": { + "@types/cordova": "^0.0.34" + } + }, "@babel/code-frame": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", @@ -2175,10 +2191,20 @@ } } }, - "@capacitor/filesystem": { + "@capacitor/device": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-1.1.0.tgz", - "integrity": "sha512-8O3UuvL8HNUEJvZnmn8yUmvgB1evtXfcF0oxIo3YbSlylqywJwS3JTiuhKmsvSxCdpbTy8IaTsutVh3gZgWbKg==" + "resolved": "https://registry.npmjs.org/@capacitor/device/-/device-1.1.0.tgz", + "integrity": "sha512-HCFwOxmK7igEgNm20y+zYi+XQ0OlZYnE4oCaI82TGmA7sehlDpBBKbjmI2Bd8aM09+BXFbAAtq7JCxkEfY8nIg==" + }, + "@capacitor/dialog": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@capacitor/dialog/-/dialog-1.0.6.tgz", + "integrity": "sha512-IzfiJv1Lxl+jnT+P6ky2Lj16qMEYssih69wFH+0lXM6NSyGht/RniBHmQ1hVuXIkw8FDT9o+a5wvYhm3svwfAw==" + }, + "@capacitor/filesystem": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-1.0.6.tgz", + "integrity": "sha512-8xqUbDZFGBMhgqoBSn9wEd9OBPdHIRegQ9zCCZcpHNf3FFAIby1ck+aDFnoq+Da49xhD6ks1SKCBSxz/26qWTw==" }, "@capacitor/haptics": { "version": "1.1.3", @@ -2196,6 +2222,16 @@ "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-1.1.3.tgz", "integrity": "sha512-WpD1f/3HH6IpADiRaFTDGdhrqYhZDikybXXhUdGAEEwHbErHt9zS5RQgbeROjGmkXcurVvQsalQ59YuKU0VzwA==" }, + "@capacitor/local-notifications": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-1.0.9.tgz", + "integrity": "sha512-6znL6l2gDj1IS05yWLyo7ENYmid89rmNTQmQxURrJLdkn98AGDNrFlYoApo7+Hfjbb3cpGX4LNKPh+uWgeaBnw==" + }, + "@capacitor/share": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-1.0.7.tgz", + "integrity": "sha512-v7FRld2SdV64YjrZrKGoDyfYqcoEC2I4tk6nkhbOI8ZOaqm6XNiqCWEeTdeb6XPwDftozmfILSzhCxbASrXKMg==" + }, "@capacitor/splash-screen": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-1.1.6.tgz", @@ -2631,6 +2667,30 @@ } } }, + "@ionic-native/file": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/@ionic-native/file/-/file-5.36.0.tgz", + "integrity": "sha512-x7yZ4VdC8n8FNlpRmUFtohNlOZnExvoxZ/6oCvGsV+ec8TJXUsDK/BYi1g+lkPTCUY3EmQIeBOe4PLO6fRJ7qg==", + "requires": { + "@types/cordova": "^0.0.34" + } + }, + "@ionic-native/file-opener": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/@ionic-native/file-opener/-/file-opener-5.36.0.tgz", + "integrity": "sha512-UKp3pbqvQXsAtLMJ5JE+KcTMxpjSZMFebf6nvy/KJvwy85JGIaCV4ZVM/H9CFUrHJMWBH6wDbY+WPygnsrl4Yg==", + "requires": { + "@types/cordova": "^0.0.34" + } + }, + "@ionic-native/file-transfer": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/@ionic-native/file-transfer/-/file-transfer-5.36.0.tgz", + "integrity": "sha512-n4kwLiPMCGvLwNDaj66Va8NWvflRJzk69RBWQSAUmQ6Hf2gE87NxLCvuvH9YRwFbFcBgEciGWzlEiacVLxx9mg==", + "requires": { + "@types/cordova": "^0.0.34" + } + }, "@ionic-native/geolocation": { "version": "5.36.0", "resolved": "https://registry.npmjs.org/@ionic-native/geolocation/-/geolocation-5.36.0.tgz", @@ -2926,6 +2986,95 @@ } } }, + "@ionic/cli": { + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/@ionic/cli/-/cli-6.18.1.tgz", + "integrity": "sha512-EIV4zln0xpI2O4kADXZCBkLsX/NIkbqjTAJOlsH7BrsPLo20e3LULQiX9rxiX20YK7ssz/0Sae1s70XTsHnTaQ==", + "dev": true, + "requires": { + "@ionic/cli-framework": "5.1.0", + "@ionic/cli-framework-output": "2.2.2", + "@ionic/cli-framework-prompts": "2.1.8", + "@ionic/utils-array": "2.1.5", + "@ionic/utils-fs": "3.1.5", + "@ionic/utils-network": "2.1.5", + "@ionic/utils-process": "2.1.8", + "@ionic/utils-stream": "3.1.5", + "@ionic/utils-subprocess": "2.1.8", + "@ionic/utils-terminal": "2.3.1", + "chalk": "^4.0.0", + "debug": "^4.0.0", + "diff": "^4.0.1", + "elementtree": "^0.1.7", + "leek": "0.0.24", + "lodash": "^4.17.5", + "open": "^7.0.4", + "os-name": "^4.0.0", + "semver": "^7.1.1", + "split2": "^3.0.0", + "ssh-config": "^1.1.1", + "stream-combiner2": "^1.1.1", + "superagent": "^5.2.1", + "superagent-proxy": "^3.0.0", + "tar": "^6.0.1", + "tslib": "^2.0.1" + }, + "dependencies": { + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "dev": true + } + } + }, + "@ionic/cli-framework": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework/-/cli-framework-5.1.0.tgz", + "integrity": "sha512-Hb/P2zuHB3zQZN0qG7Lxda8IlP2mHisfb0KR+wc9cw2BSiH+rtXRd/A4JxndPznjWs00PHbWiEm0Ehas2pA/nw==", + "dev": true, + "requires": { + "@ionic/cli-framework-output": "2.2.2", + "@ionic/utils-array": "2.1.5", + "@ionic/utils-fs": "3.1.5", + "@ionic/utils-object": "2.1.5", + "@ionic/utils-process": "2.1.8", + "@ionic/utils-stream": "3.1.5", + "@ionic/utils-subprocess": "2.1.8", + "@ionic/utils-terminal": "2.3.1", + "chalk": "^4.0.0", + "debug": "^4.0.0", + "lodash": "^4.17.5", + "minimist": "^1.2.0", + "rimraf": "^3.0.0", + "tslib": "^2.0.1", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "dev": true + } + } + }, "@ionic/cli-framework-output": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.2.tgz", @@ -2945,6 +3094,47 @@ } } }, + "@ionic/cli-framework-prompts": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-prompts/-/cli-framework-prompts-2.1.8.tgz", + "integrity": "sha512-DjO4lQsmvficsZbPmpGqSSx+F1BfgSTQBwRqL5bl9Dkh9rIZ/ckcJcKqCciVOw9kIY7WTeNFOTwj2vWrkFn7+Q==", + "dev": true, + "requires": { + "@ionic/utils-terminal": "2.3.1", + "debug": "^4.0.0", + "inquirer": "^7.0.0", + "tslib": "^2.0.1" + }, + "dependencies": { + "inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.19", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.6.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "dev": true + } + } + }, "@ionic/core": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.7.0.tgz", @@ -3035,6 +3225,24 @@ } } }, + "@ionic/utils-network": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-network/-/utils-network-2.1.5.tgz", + "integrity": "sha512-HUQ1Ec4Mh2MXzzKdbbbDS6xYKwpFJ2XRY7SYXbaZT8+jiNahfHbsOfe62/p8bk41Yil7E9EagzGC2JvIFJh01w==", + "dev": true, + "requires": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "dev": true + } + } + }, "@ionic/utils-object": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.5.tgz", @@ -3848,6 +4056,11 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==" }, + "@transistorsoft/capacitor-background-fetch": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@transistorsoft/capacitor-background-fetch/-/capacitor-background-fetch-0.0.6.tgz", + "integrity": "sha512-MnaPPuEzEty8jjnrd2blrifTG++/DrLeAcyxn8r42VYWD7p5Gv3xbNxOM81XOsLj1GpTJ571+msq8R7Z+gdqFg==" + }, "@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -3967,6 +4180,11 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + }, "@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -6957,6 +7175,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", + "dev": true + }, "copy-anything": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", @@ -7036,6 +7260,11 @@ } } }, + "cordova-plugin-calendar": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/cordova-plugin-calendar/-/cordova-plugin-calendar-5.1.5.tgz", + "integrity": "sha512-Qrz+Yo3ifpCsi0CWfLFrnP37Tgp3jxDmtNKILhU+f3g2EsbWwLc7VsKq8zCrxJihGr/vKpEoSFtWZGpEM3zobQ==" + }, "cordova-plugin-device": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/cordova-plugin-device/-/cordova-plugin-device-2.0.3.tgz", @@ -7637,6 +7866,12 @@ "assert-plus": "^1.0.0" } }, + "data-uri-to-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", + "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==", + "dev": true + }, "date-format": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.3.tgz", @@ -7845,6 +8080,35 @@ } } }, + "degenerator": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-3.0.1.tgz", + "integrity": "sha512-LFsIFEeLPlKvAKXu7j3ssIG6RT0TbI7/GhsqrI0DnHASEQjXQ0LUSYcjJteGgRGmZbl1TnMSxpNQIAiJ7Du5TQ==", + "dev": true, + "requires": { + "ast-types": "^0.13.2", + "escodegen": "^1.8.1", + "esprima": "^4.0.0", + "vm2": "^3.9.3" + }, + "dependencies": { + "ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "requires": { + "tslib": "^2.0.1" + } + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "dev": true + } + } + }, "del": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/del/-/del-6.0.0.tgz", @@ -9422,6 +9686,12 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -9592,6 +9862,12 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", + "dev": true + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -9691,6 +9967,36 @@ } } }, + "ftp": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", + "integrity": "sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=", + "dev": true, + "requires": { + "readable-stream": "1.1.x", + "xregexp": "2.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -9872,6 +10178,54 @@ "pump": "^3.0.0" } }, + "get-uri": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-3.0.2.tgz", + "integrity": "sha512-+5s0SJbGoyiJTZZ2JTpFPLMPSch72KEqGOTvQsBqg0RBWvwhWUSYZFAtz3TPW0GXJuLBJPts1E241iHg+VRfhg==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "data-uri-to-buffer": "3", + "debug": "4", + "file-uri-to-path": "2", + "fs-extra": "^8.1.0", + "ftp": "^0.3.10" + }, + "dependencies": { + "file-uri-to-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-2.0.0.tgz", + "integrity": "sha512-hjPFI8oE/2iQPVe4gbrJ73Pp+Xfub2+WI2LlXDbsaJBwT5wuMh35WNWVYYTpnz895shtwfyutMFLFywpQAFdLg==", + "dev": true + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + } + } + }, "get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -11407,12 +11761,6 @@ } } }, - "jetifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jetifier/-/jetifier-2.0.0.tgz", - "integrity": "sha512-J4Au9KuT74te+PCCCHKgAjyLlEa+2VyIAEPNCdE5aNkAJ6FAJcAqcdzEkSnzNksIa9NkGmC4tPiClk2e7tCJuQ==", - "dev": true - }, "joi": { "version": "17.5.0", "resolved": "https://registry.npmjs.org/joi/-/joi-17.5.0.tgz", @@ -11937,6 +12285,34 @@ "npm-ci": "0.0.2" } }, + "leek": { + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/leek/-/leek-0.0.24.tgz", + "integrity": "sha1-5ADlfw5g2O8r1NBo3EKKVDRdvNo=", + "dev": true, + "requires": { + "debug": "^2.1.0", + "lodash.assign": "^3.2.0", + "rsvp": "^3.0.21" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, "less": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/less/-/less-4.1.1.tgz", @@ -12219,18 +12595,97 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, + "lodash._baseassign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", + "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=", + "dev": true, + "requires": { + "lodash._basecopy": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true + }, + "lodash._bindcallback": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz", + "integrity": "sha1-5THCdkTPi1epnhftlbNcdIeJOS4=", + "dev": true + }, + "lodash._createassigner": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz", + "integrity": "sha1-g4pbri/aymOsIt7o4Z+k5taXCxE=", + "dev": true, + "requires": { + "lodash._bindcallback": "^3.0.0", + "lodash._isiterateecall": "^3.0.0", + "lodash.restparam": "^3.0.0" + } + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true + }, + "lodash.assign": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz", + "integrity": "sha1-POnwI0tLIiPilrj6CsH+6OvKZPo=", + "dev": true, + "requires": { + "lodash._baseassign": "^3.0.0", + "lodash._createassigner": "^3.0.0", + "lodash.keys": "^3.0.0" + } + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true + }, "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", "dev": true }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, + "requires": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -12242,6 +12697,12 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "lodash.restparam": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz", + "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=", + "dev": true + }, "lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -13058,6 +13519,12 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true + }, "netrc": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/netrc/-/netrc-0.1.4.tgz", @@ -13666,6 +14133,47 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "pac-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-5.0.0.tgz", + "integrity": "sha512-CcFG3ZtnxO8McDigozwE3AqAw15zDvGH+OjXO4kzf7IkEKkQ4gxQ+3sdF50WmhQ4P/bVusXcqNE2S3XrNURwzQ==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4", + "get-uri": "3", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "5", + "pac-resolver": "^5.0.0", + "raw-body": "^2.2.0", + "socks-proxy-agent": "5" + }, + "dependencies": { + "socks-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-5.0.1.tgz", + "integrity": "sha512-vZdmnjb9a2Tz6WEQVIurybSwElwPxMZaIc7PzqbJTrezcKNznv6giT7J7tZDZ1BojVaa1jvO/UiUdhDVB0ACoQ==", + "dev": true, + "requires": { + "agent-base": "^6.0.2", + "debug": "4", + "socks": "^2.3.3" + } + } + } + }, + "pac-resolver": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-5.0.0.tgz", + "integrity": "sha512-H+/A6KitiHNNW+bxBKREk2MCGSxljfqRX76NjummWEYIat7ldVXRU3dhRIE3iXZ0nvGBk6smv3nntxKkzRL8NA==", + "dev": true, + "requires": { + "degenerator": "^3.0.1", + "ip": "^1.1.5", + "netmask": "^2.0.1" + } + }, "pacote": { "version": "11.3.5", "resolved": "https://registry.npmjs.org/pacote/-/pacote-11.3.5.tgz", @@ -15958,6 +16466,56 @@ "ipaddr.js": "1.9.1" } }, + "proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-5.0.0.tgz", + "integrity": "sha512-gkH7BkvLVkSfX9Dk27W6TyNOWWZWRilRfk1XxGNWOYJ2TuedAv1yFpCaU9QSBmBe716XOTNpYNOzhysyw8xn7g==", + "dev": true, + "requires": { + "agent-base": "^6.0.0", + "debug": "4", + "http-proxy-agent": "^4.0.0", + "https-proxy-agent": "^5.0.0", + "lru-cache": "^5.1.1", + "pac-proxy-agent": "^5.0.0", + "proxy-from-env": "^1.0.0", + "socks-proxy-agent": "^5.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "socks-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-5.0.1.tgz", + "integrity": "sha512-vZdmnjb9a2Tz6WEQVIurybSwElwPxMZaIc7PzqbJTrezcKNznv6giT7J7tZDZ1BojVaa1jvO/UiUdhDVB0ACoQ==", + "dev": true, + "requires": { + "agent-base": "^6.0.2", + "debug": "4", + "socks": "^2.3.3" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -16744,6 +17302,12 @@ "glob": "^7.1.3" } }, + "rsvp": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-3.6.2.tgz", + "integrity": "sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw==", + "dev": true + }, "run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -17674,6 +18238,12 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "ssh-config": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ssh-config/-/ssh-config-1.1.6.tgz", + "integrity": "sha512-ZPO9rECxzs5JIQ6G/2EfL1I9ho/BVZkx9HRKn8+0af7QgwAmumQ7XBFP1ggMyPMo+/tUbmv0HFdv4qifdO/9JA==", + "dev": true + }, "sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -17849,6 +18419,54 @@ "through": "~2.3.4" } }, + "stream-combiner2": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", + "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=", + "dev": true, + "requires": { + "duplexer2": "~0.1.0", + "readable-stream": "^2.0.2" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "streamroller": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.0.2.tgz", @@ -17995,6 +18613,54 @@ "resolved": "https://registry.npmjs.org/suncalc/-/suncalc-1.8.0.tgz", "integrity": "sha1-HZiYEJVjB4dQ9JlKlZ5lTYdqy/U=" }, + "superagent": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-5.3.1.tgz", + "integrity": "sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==", + "dev": true, + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.1", + "fast-safe-stringify": "^2.0.7", + "form-data": "^3.0.0", + "formidable": "^1.2.2", + "methods": "^1.1.2", + "mime": "^2.4.6", + "qs": "^6.9.4", + "readable-stream": "^3.6.0", + "semver": "^7.3.2" + }, + "dependencies": { + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + } + } + }, + "superagent-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/superagent-proxy/-/superagent-proxy-3.0.0.tgz", + "integrity": "sha512-wAlRInOeDFyd9pyonrkJspdRAxdLrcsZ6aSnS+8+nu4x1aXbz6FWSTT9M6Ibze+eG60szlL7JA8wEIV7bPWuyQ==", + "dev": true, + "requires": { + "debug": "^4.3.2", + "proxy-agent": "^5.0.0" + } + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -18893,6 +19559,15 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, "typescript": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", @@ -19204,6 +19879,12 @@ "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==" }, + "vm2": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/vm2/-/vm2-3.9.5.tgz", + "integrity": "sha512-LuCAHZN75H9tdrAiLFf030oW7nJV5xwNMuk1ymOZwopmuK3d2H4L1Kv4+GFHgarKiLfXXLFU+7LDABHnwOkWng==", + "dev": true + }, "void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", @@ -20079,6 +20760,18 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "ws": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz", @@ -20129,6 +20822,12 @@ } } }, + "xregexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", + "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM=", + "dev": true + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 3019d3e5..a7ea1c96 100644 --- a/package.json +++ b/package.json @@ -54,18 +54,28 @@ "@angular/router": "12.2.13", "@asymmetrik/ngx-leaflet": "8.1.0", "@asymmetrik/ngx-leaflet-markercluster": "5.0.1", + "@awesome-cordova-plugins/calendar": "5.37.1", + "@awesome-cordova-plugins/core": "5.37.1", "@capacitor-community/http": "1.4.1", "@capacitor/app": "1.0.6", "@capacitor/browser": "1.0.6", "@capacitor/core": "3.3.1", + "@capacitor/device": "1.1.0", + "@capacitor/dialog": "1.0.6", + "@capacitor/filesystem": "1.0.6", "@capacitor/haptics": "1.1.3", "@capacitor/keyboard": "1.1.3", + "@capacitor/local-notifications": "1.0.9", + "@capacitor/share": "1.0.7", "@capacitor/splash-screen": "1.1.6", "@capacitor/status-bar": "1.0.6", "@capacitor/storage": "1.2.3", "@ionic-native/core": "5.36.0", "@ionic-native/diagnostic": "5.36.0", "@ionic-native/dialogs": "5.36.0", + "@ionic-native/file": "5.36.0", + "@ionic-native/file-opener": "5.36.0", + "@ionic-native/file-transfer": "5.36.0", "@ionic-native/geolocation": "5.36.0", "@ionic-native/network": "5.36.0", "@ionic/angular": "5.7.0", @@ -75,7 +85,9 @@ "@openstapps/api": "0.38.0", "@openstapps/configuration": "0.29.0", "@openstapps/core": "0.63.0", + "@transistorsoft/capacitor-background-fetch": "0.0.6", "capacitor-secure-storage-plugin": "0.6.2", + "cordova-plugin-calendar": "5.1.5", "cordova-plugin-device": "2.0.3", "cordova-plugin-dialogs": "2.0.2", "cordova-plugin-geolocation": "4.1.0", @@ -121,6 +133,7 @@ "@capacitor/ios": "3.3.2", "@compodoc/compodoc": "1.1.14", "@ionic/angular-toolkit": "4.0.0", + "@ionic/cli": "6.18.1", "@types/deepmerge": "2.2.0", "@types/form-data": "2.5.0", "@types/jasmine": "3.9.1", @@ -142,7 +155,6 @@ "is-docker": "1.1.0", "jasmine-core": "3.9.0", "jasmine-spec-reporter": "7.0.0", - "jetifier": "2.0.0", "karma": "6.3.4", "karma-chrome-launcher": "3.1.0", "karma-coverage-istanbul-reporter": "3.0.3", @@ -159,6 +171,9 @@ "cordova": { "plugins": { "cordova-plugin-whitelist": {}, + "cordova-plugin-file-transfer": { + "ANDROID_SUPPORT_V4_VERSION": "27.+" + }, "cordova-plugin-device": {}, "cordova-plugin-geolocation": { "GEOLOCATION_USAGE_DESCRIPTION": "The app will use your location to provide features for navigation or distances information.", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index c8e92f99..f6282b75 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -27,6 +27,7 @@ import {ConfigProvider} from './modules/config/config.provider'; import {SettingsProvider} from './modules/settings/settings.provider'; import {NGXLogger} from 'ngx-logger'; import {RouterTestingModule} from '@angular/router/testing'; +import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service'; describe('AppComponent', () => { let platformReadySpy: any; @@ -36,6 +37,7 @@ describe('AppComponent', () => { let settingsProvider: jasmine.SpyObj; let configProvider: jasmine.SpyObj; let ngxLogger: jasmine.SpyObj; + let scheduleSyncServiceSpy: jasmine.SpyObj; let platformIsSpy; @@ -59,6 +61,10 @@ describe('AppComponent', () => { 'provideSetting', 'setCategoriesOrder', ]); + scheduleSyncServiceSpy = jasmine.createSpyObj('ScheduleSyncService', [ + 'getDifferences', + 'postDifferencesNotification', + ]); configProvider = jasmine.createSpyObj('ConfigProvider', ['init']); ngxLogger = jasmine.createSpyObj('NGXLogger', ['log', 'error', 'warn']); @@ -73,6 +79,7 @@ describe('AppComponent', () => { {provide: Platform, useValue: platformSpy}, {provide: TranslateService, useValue: translateServiceSpy}, {provide: ThingTranslateService, useValue: thingTranslateServiceSpy}, + {provide: ScheduleSyncService, useValue: scheduleSyncServiceSpy}, {provide: SettingsProvider, useValue: settingsProvider}, {provide: ConfigProvider, useValue: configProvider}, {provide: NGXLogger, useValue: ngxLogger}, diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 89523345..54198d77 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,7 +12,7 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {Component, NgZone} from '@angular/core'; +import {AfterContentInit, Component, NgZone} from '@angular/core'; import {Router} from '@angular/router'; import {App, URLOpenListenerEvent} from '@capacitor/app'; import {SplashScreen} from '@capacitor/splash-screen'; @@ -24,6 +24,7 @@ import {PAIAAuthService} from './modules/auth/paia/paia-auth.service'; import {DefaultAuthService} from './modules/auth/default-auth.service'; import {environment} from '../environments/environment'; import {AuthHelperService} from './modules/auth/auth-helper.service'; +import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service'; /** * TODO @@ -32,7 +33,7 @@ import {AuthHelperService} from './modules/auth/auth-helper.service'; selector: 'app-root', templateUrl: 'app.component.html', }) -export class AppComponent { +export class AppComponent implements AfterContentInit { /** * TODO */ @@ -59,6 +60,7 @@ export class AppComponent { * @param paiaAuth Auth Service * @param authHelperService Helper service for OAuth providers * @param toastController Toast controller + * @param scheduleSync TODO */ constructor( private readonly platform: Platform, @@ -71,10 +73,15 @@ export class AppComponent { private readonly paiaAuth: PAIAAuthService, private readonly authHelperService: AuthHelperService, private readonly toastController: ToastController, + private readonly scheduleSync: ScheduleSyncService, ) { void this.initializeApp(); } + ngAfterContentInit() { + void this.scheduleSync.enable(); + } + /** * TODO */ diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9499e96c..249961d5 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -63,6 +63,8 @@ import {DebugDataCollectorService} from './modules/data/debug-data-collector.ser import {Browser} from './util/browser.factory'; import {browserFactory} from './util/browser.factory'; import {AuthModule} from './modules/auth/auth.module'; +import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service'; +import {BackgroundModule} from './modules/background/background.module'; import {LibraryModule} from './modules/library/library.module'; registerLocaleData(localeDe); @@ -134,7 +136,13 @@ const providers: Provider[] = [ { provide: APP_INITIALIZER, multi: true, - deps: [NGXLogger, SettingsProvider, ConfigProvider, TranslateService], + deps: [ + NGXLogger, + SettingsProvider, + ConfigProvider, + TranslateService, + ScheduleSyncService, + ], useFactory: initSettingsFactory, }, ]; @@ -149,6 +157,7 @@ const providers: Provider[] = [ AboutModule, AppRoutingModule, AuthModule, + BackgroundModule, BrowserModule, BrowserAnimationsModule, CatalogModule, diff --git a/src/app/modules/background/background.module.ts b/src/app/modules/background/background.module.ts new file mode 100644 index 00000000..e1397f3d --- /dev/null +++ b/src/app/modules/background/background.module.ts @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {NgModule} from '@angular/core'; +import {ScheduleSyncService} from './schedule/schedule-sync.service'; +import {DateFormatPipe, DurationPipe} from 'ngx-moment'; +import {CalendarModule} from '../calendar/calendar.module'; +import {ScheduleProvider} from '../calendar/schedule.provider'; +import {StorageProvider} from '../storage/storage.provider'; +import {CalendarService} from '../calendar/calendar.service'; + +/** + * Schedule Module + */ +@NgModule({ + declarations: [], + imports: [CalendarModule], + providers: [ + DurationPipe, + DateFormatPipe, + ScheduleProvider, + StorageProvider, + CalendarService, + ScheduleSyncService, + ], +}) +export class BackgroundModule {} diff --git a/src/app/modules/background/schedule/changes.ts b/src/app/modules/background/schedule/changes.ts new file mode 100644 index 00000000..ac679e96 --- /dev/null +++ b/src/app/modules/background/schedule/changes.ts @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +export interface ChangesOf> { + new: T; + old?: P; + changes: Array; +} diff --git a/src/app/modules/background/schedule/hash.ts b/src/app/modules/background/schedule/hash.ts new file mode 100644 index 00000000..6148f416 --- /dev/null +++ b/src/app/modules/background/schedule/hash.ts @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +/** + * + */ +export function hashStringToInt(string_: string): number { + return [...string_].reduce( + (accumulator, current) => + current.charCodeAt(0) + + (accumulator << 6) + + (accumulator << 16) - + accumulator, + 0, + ); +} diff --git a/src/app/modules/background/schedule/schedule-sync.service.ts b/src/app/modules/background/schedule/schedule-sync.service.ts new file mode 100644 index 00000000..3a7e7693 --- /dev/null +++ b/src/app/modules/background/schedule/schedule-sync.service.ts @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {Injectable, OnDestroy} from '@angular/core'; +import { + DateSeriesRelevantData, + dateSeriesRelevantKeys, + formatRelevantKeys, + ScheduleProvider, +} from '../../calendar/schedule.provider'; +import {SCDateSeries, SCThingType, SCUuid} from '@openstapps/core'; +import {Device} from '@capacitor/device'; +import {LocalNotifications} from '@capacitor/local-notifications'; +import {ThingTranslateService} from '../../../translation/thing-translate.service'; +import {DateFormatPipe, DurationPipe} from 'ngx-moment'; +import {BackgroundFetch} from '@transistorsoft/capacitor-background-fetch'; +import {StorageProvider} from '../../storage/storage.provider'; +import {CalendarService} from '../../calendar/calendar.service'; +import {flatMap} from 'lodash-es'; +import {toICal} from '../../calendar/ical/ical'; +import {Subscription} from 'rxjs'; +import {ChangesOf} from './changes'; +import {hashStringToInt} from './hash'; +import { + CALENDAR_NOTIFICATIONS_ENABLED_KEY, + CALENDAR_SYNC_ENABLED_KEY, + CALENDAR_SYNC_SETTINGS_KEY, +} from '../../settings/page/calendar-sync-settings-keys'; + +@Injectable() +export class ScheduleSyncService implements OnDestroy { + constructor( + private scheduleProvider: ScheduleProvider, + private storageProvider: StorageProvider, + private translator: ThingTranslateService, + private dateFormatPipe: DateFormatPipe, + private durationFormatPipe: DurationPipe, + private calendar: CalendarService, + ) { + this.scheduleProvider.uuids$.subscribe(uuids => { + this.uuids = uuids; + void this.syncNativeCalendar(); + }); + } + + uuids: SCUuid[]; + + uuidSubscription: Subscription; + + ngOnDestroy() { + this.uuidSubscription?.unsubscribe(); + } + + private async isSyncEnabled(): Promise { + return await this.storageProvider.get( + `${CALENDAR_SYNC_SETTINGS_KEY}.${CALENDAR_SYNC_ENABLED_KEY}`, + ); + } + + private async isNotificationsEnabled(): Promise { + return await this.storageProvider.get( + `${CALENDAR_SYNC_SETTINGS_KEY}.${CALENDAR_NOTIFICATIONS_ENABLED_KEY}`, + ); + } + + async enable() { + if ((await Device.getInfo()).platform === 'web') return; + + await BackgroundFetch.stop(); + + if ( + [this.isSyncEnabled, this.isNotificationsEnabled].some( + async it => await it(), + ) + ) { + const status = await BackgroundFetch.configure( + { + minimumFetchInterval: 15, + stopOnTerminate: false, + enableHeadless: true, + }, + async taskId => { + await Promise.all([ + this.postDifferencesNotification(), + this.syncNativeCalendar(), + ]); + + await BackgroundFetch.finish(taskId); + }, + ); + + if (status !== BackgroundFetch.STATUS_AVAILABLE) { + if (status === BackgroundFetch.STATUS_DENIED) { + console.error( + 'The user explicitly disabled background behavior for this app or for the whole system.', + ); + } else if (status === BackgroundFetch.STATUS_RESTRICTED) { + console.error( + 'Background updates are unavailable and the user cannot enable them again.', + ); + } + } else { + console.info('Starting background fetch.'); + + await BackgroundFetch.start(); + } + } + } + + async getDifferences(): Promise< + ChangesOf[] + > { + const partialEvents = this.scheduleProvider.partialEvents$.getValue(); + + const result = ( + await this.scheduleProvider.getDateSeries(partialEvents.map(it => it.uid)) + ).dates; + + return result + .map(it => ({ + new: it, + old: partialEvents.find(partialEvent => partialEvent.uid === it.uid), + })) + .map(it => ({ + ...it, + changes: it.old + ? (Object.keys(it.old) as Array).filter( + key => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + JSON.stringify(it.old![key]) !== JSON.stringify(it.new[key]), + ) + : dateSeriesRelevantKeys, + })); + } + + private formatChanges( + changes: ChangesOf, + ): string[] { + return changes.changes.map( + change => + `${ + this.translator.translator.translatedPropertyNames( + SCThingType.DateSeries, + )?.[change] + }: ${formatRelevantKeys[change]( + changes.new[change] as never, + this.dateFormatPipe, + this.durationFormatPipe, + )}`, + ); + } + + async syncNativeCalendar() { + if (!(await this.isSyncEnabled())) return; + + const dateSeries = (await this.scheduleProvider.getDateSeries(this.uuids)) + .dates; + + const events = flatMap(dateSeries, event => + toICal(event, this.translator.translator, { + allowRRuleExceptions: false, + excludeCancelledEvents: true, + }), + ); + + return this.calendar.syncEvents(events); + } + + async postDifferencesNotification() { + if (!(await this.isNotificationsEnabled())) return; + + const differences = (await this.getDifferences()).filter( + it => it.changes.length > 0, + ); + if (differences.length === 0) return; + + if ((await Device.getInfo()).platform === 'web') { + // TODO: Implement web notification + } else { + await LocalNotifications.schedule({ + notifications: differences.map(it => ({ + title: it.new.event.name, + body: this.formatChanges(it).join('\n'), + id: hashStringToInt(it.new.uid), + })), + }); + } + } +} diff --git a/src/app/modules/calendar/add-event-review-modal.component.ts b/src/app/modules/calendar/add-event-review-modal.component.ts new file mode 100644 index 00000000..e11427a5 --- /dev/null +++ b/src/app/modules/calendar/add-event-review-modal.component.ts @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import {Component, Input, OnInit} from '@angular/core'; +import { + getICalExport, + getNativeCalendarExport, + ICalEvent, + serializeICal, + toICal, + toICalUpdates, +} from './ical/ical'; +import moment from 'moment'; +import {Share} from '@capacitor/share'; +import {Directory, Encoding, Filesystem} from '@capacitor/filesystem'; +import {Device} from '@capacitor/device'; +import {CalendarService} from './calendar.service'; +import {Dialog} from '@capacitor/dialog'; +import {SCDateSeries} from '@openstapps/core'; +import {ThingTranslateService} from '../../translation/thing-translate.service'; +import {TranslateService} from '@ngx-translate/core'; +import {NewShareData, NewShareNavigator} from './new-share'; + +interface ICalInfo { + title: string; + events: ICalEvent[]; + cancelledEvents: ICalEvent[]; +} + +@Component({ + selector: 'add-event-review-modal', + templateUrl: 'add-event-review-modal.html', + styleUrls: ['add-event-review-modal.scss'], +}) +export class AddEventReviewModalComponent implements OnInit { + moment = moment; + + @Input() dismissAction: () => void; + + @Input() dateSeries: SCDateSeries[]; + + iCalEvents: ICalInfo[]; + + includeCancelled = true; + + isWeb = true; + + constructor( + readonly calendarService: CalendarService, + readonly translator: ThingTranslateService, + readonly translateService: TranslateService, + ) {} + + ngOnInit() { + Device.getInfo().then(it => { + this.isWeb = it.platform === 'web'; + }); + + this.iCalEvents = this.dateSeries.map(event => ({ + title: + this.translator.translator.translatedAccess(event).event.name() ?? + 'error', + events: toICal(event, this.translator.translator, { + allowRRuleExceptions: true, + excludeCancelledEvents: false, + }), + cancelledEvents: toICalUpdates(event, this.translator.translator), + })); + } + + async toCalendar() { + await Dialog.confirm({ + title: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.toCalendarConfirm.TITLE', + ), + message: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.toCalendarConfirm.DESCRIPTION', + ), + }); + + await this.calendarService.syncEvents( + getNativeCalendarExport(this.dateSeries, this.translator.translator), + ); + + this.dismissAction(); + } + + async download() { + const blob = new Blob( + [ + serializeICal( + getICalExport( + this.dateSeries, + this.translator.translator, + this.includeCancelled, + ), + ), + ], + { + type: 'text/calendar', + }, + ); + const url = window.URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `${ + this.dateSeries.length === 1 + ? this.dateSeries[0].event.name + : 'stapps_calendar' + }.ics`; + a.click(); + } + + async export() { + const info = await Device.getInfo(); + + if (info.platform === 'web') { + const blob = new Blob( + [ + serializeICal( + getICalExport( + this.dateSeries, + this.translator.translator, + this.includeCancelled, + ), + ), + ], + { + type: 'text/calendar', + }, + ); + const file = new File([blob], 'calendar.ics', {type: blob.type}); + const shareData: NewShareData = { + files: [file], + title: this.translateService.instant( + 'schedule.toCalendar.reviewModal.shareData.TITLE', + ), + text: this.translateService.instant( + 'schedule.toCalendar.reviewModal.shareData.TEXT', + ), + }; + + if (!(navigator as unknown as NewShareNavigator).canShare) { + return Dialog.alert({ + title: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.cannotShare.TITLE', + ), + message: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.cannotShare.DESCRIPTION', + ), + }); + } + console.log( + (navigator as unknown as NewShareNavigator).canShare(shareData), + ); + + if (!(navigator as unknown as NewShareNavigator).canShare(shareData)) { + return Dialog.alert({ + title: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.unsupportedFileType.TITLE', + ), + message: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.unsupportedFileType.DESCRIPTION', + ), + }); + } + try { + await (navigator as unknown as NewShareNavigator).share(shareData); + } catch (error) { + console.log(error); + return Dialog.alert({ + title: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.failedShare.TITLE', + ), + message: this.translateService.instant( + 'schedule.toCalendar.reviewModal.dialogs.failedShare.DESCRIPTION', + ), + }); + } + } else { + const result = await Filesystem.writeFile({ + path: `${ + this.dateSeries.length === 1 + ? this.dateSeries[0].event.name + : this.translateService.instant( + 'schedule.toCalendar.reviewModal.shareData.FILE_TYPE', + ) + }.ics`, + data: serializeICal( + getICalExport( + this.dateSeries, + this.translator.translator, + this.includeCancelled, + ), + ), + encoding: Encoding.UTF8, + directory: Directory.Cache, + }); + + await Share.share({ + title: this.translateService.instant( + 'schedule.toCalendar.reviewModal.shareData.TITLE', + ), + text: this.translateService.instant( + 'schedule.toCalendar.reviewModal.shareData.TEXT', + ), + url: result.uri, + dialogTitle: this.translateService.instant( + 'schedule.toCalendar.reviewModal.shareData.TITLE', + ), + }); + } + } +} diff --git a/src/app/modules/calendar/add-event-review-modal.html b/src/app/modules/calendar/add-event-review-modal.html new file mode 100644 index 00000000..b9b31bbb --- /dev/null +++ b/src/app/modules/calendar/add-event-review-modal.html @@ -0,0 +1,81 @@ + +
+ + {{ + 'schedule.toCalendar.reviewModal.TITLE' | translate + }} + + {{ 'modal.DISMISS' | translate }} + + + + + + + + {{ event.title }} + + + + + + + + + + {{ moment(iCalEvent.start) | amDateFormat: 'll, HH:mm' }} + + + + {{ iCalEvent.rrule.interval }} + {{ iCalEvent.rrule.freq | sentencecase }} + + + + + + + +
+ + {{ + 'schedule.toCalendar.reviewModal.INCLUDE_CANCELLED' | translate + }} + + +
+
+ + {{ 'share' | translate }} + + + + {{ 'schedule.toCalendar.reviewModal.DOWNLOAD' | translate }} + + + + + {{ 'schedule.toCalendar.reviewModal.EXPORT' | translate }} + + + +
+
diff --git a/src/app/modules/calendar/add-event-review-modal.scss b/src/app/modules/calendar/add-event-review-modal.scss new file mode 100644 index 00000000..2122028f --- /dev/null +++ b/src/app/modules/calendar/add-event-review-modal.scss @@ -0,0 +1,29 @@ +div { + height: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + + ion-card-header { + ion-button { + position: absolute; + right: 0; + top: 0; + } + } + + ion-card-content { + height: 100%; + overflow: scroll; + padding-left: 0; + padding-right: 0; + } +} + +.horizontal-flex { + height: fit-content; + display: flex; + flex-direction: row; + justify-content: end; + align-items: center; +} diff --git a/src/app/modules/calendar/calendar-info.ts b/src/app/modules/calendar/calendar-info.ts new file mode 100644 index 00000000..1506dfc0 --- /dev/null +++ b/src/app/modules/calendar/calendar-info.ts @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +export interface CalendarInfo { + id: number; + name: string; + displayname: string; + isPrimary: boolean; +} diff --git a/src/app/modules/calendar/calendar.module.ts b/src/app/modules/calendar/calendar.module.ts new file mode 100644 index 00000000..d398f494 --- /dev/null +++ b/src/app/modules/calendar/calendar.module.ts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {NgModule} from '@angular/core'; +import {AddEventReviewModalComponent} from './add-event-review-modal.component'; +import {Calendar} from '@awesome-cordova-plugins/calendar/ngx'; +import {CalendarService} from './calendar.service'; +import {ScheduleProvider} from './schedule.provider'; +import {IonicModule} from '@ionic/angular'; +import {TranslateModule} from '@ngx-translate/core'; +import {ThingTranslateModule} from '../../translation/thing-translate.module'; +import {FormsModule} from '@angular/forms'; +import {CommonModule} from '@angular/common'; +import {MomentModule} from 'ngx-moment'; + +@NgModule({ + declarations: [AddEventReviewModalComponent], + imports: [ + IonicModule.forRoot(), + TranslateModule.forChild(), + ThingTranslateModule.forChild(), + FormsModule, + CommonModule, + MomentModule, + ], + exports: [], + providers: [Calendar, CalendarService, ScheduleProvider], +}) +export class CalendarModule {} diff --git a/src/app/modules/calendar/calendar.service.ts b/src/app/modules/calendar/calendar.service.ts new file mode 100644 index 00000000..a6267a19 --- /dev/null +++ b/src/app/modules/calendar/calendar.service.ts @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {Calendar} from '@awesome-cordova-plugins/calendar/ngx'; +import {Injectable} from '@angular/core'; +import {ICalEvent} from './ical/ical'; +import moment, {duration, unitOfTime} from 'moment'; +import {Dialog} from '@capacitor/dialog'; +import {CalendarInfo} from './calendar-info'; + +const CALENDAR_NAME = 'StApps'; + +const RECURRENCE_PATTERNS: Partial< + Record +> = { + year: 'yearly', + month: 'monthly', + week: 'weekly', + day: 'daily', +}; + +@Injectable() +export class CalendarService { + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor(readonly calendar: Calendar) {} + + async createCalendar(): Promise { + await this.calendar.createCalendar({ + calendarName: CALENDAR_NAME, + calendarColor: '#ff8740', + }); + return this.findCalendar(CALENDAR_NAME); + } + + async listCalendars(): Promise { + return this.calendar.listCalendars(); + } + + async findCalendar(name: string): Promise { + return (await this.listCalendars())?.find( + (calendar: CalendarInfo) => calendar.name === name, + ); + } + + async purge(): Promise { + if (await this.findCalendar(CALENDAR_NAME)) { + await this.calendar.deleteCalendar(CALENDAR_NAME); + } + return await this.createCalendar(); + } + + async syncEvents(events: ICalEvent[]) { + const calendar = await this.purge(); + if (!calendar) { + return Dialog.alert({ + title: 'Error', + message: 'Could not create calendar', + }); + } + + for (const iCalEvent of events) { + // TODO: change to use non-interactive version after testing is complete + const start = iCalEvent.rrule ? iCalEvent.rrule.from : iCalEvent.start; + + await this.calendar.createEventWithOptions( + iCalEvent.recurrenceSequence + ? `(${iCalEvent.recurrenceSequence}/${iCalEvent.recurrenceSequenceAmount}) ${iCalEvent.name}` + : iCalEvent.name, + iCalEvent.geo, + iCalEvent.description, + new Date(start), + moment(start).add(duration(iCalEvent.duration)).toDate(), + { + id: `${iCalEvent.uuid}-${start}`, + url: iCalEvent.url, + calendarName: calendar.name, + calendarId: calendar.id, + ...(iCalEvent.rrule + ? { + recurrence: RECURRENCE_PATTERNS[iCalEvent.rrule.freq], + recurrenceInterval: iCalEvent.rrule.interval, + recurrenceEndDate: new Date(iCalEvent.rrule.until), + } + : {}), + }, + ); + } + } +} diff --git a/src/app/modules/calendar/ical/ical.spec.ts b/src/app/modules/calendar/ical/ical.spec.ts new file mode 100644 index 00000000..50e0a747 --- /dev/null +++ b/src/app/modules/calendar/ical/ical.spec.ts @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {findRRules, RRule} from './ical'; +import moment, {unitOfTime} from 'moment'; +import {shuffle} from 'lodash-es'; +import {SCISO8601Date} from '@openstapps/core'; + +/** + * + */ +function expandRRule(rule: RRule): SCISO8601Date[] { + const initial = moment(rule.from); + const interval = rule.interval ?? 1; + + return shuffle( + Array.from({ + length: + Math.floor( + moment(rule.until).diff(initial, rule.freq, true) / interval, + ) + 1, + }).map((_, i) => + initial + .clone() + .add(interval * i, rule.freq ?? 'day') + .toISOString(), + ), + ); +} + +describe('iCal', () => { + it('should find simple recurrence patterns', () => { + for (const freq of ['day', 'week', 'month', 'year'] as unitOfTime.Diff[]) { + for (const interval of [1, 2, 3]) { + const pattern: RRule = { + freq: freq, + interval: interval, + from: moment('2021-09-01T10:00').toISOString(), + until: moment('2021-09-01T10:00') + .add(4 * interval, freq) + .toISOString(), + }; + + expect(findRRules(expandRRule(pattern))).toEqual([pattern]); + } + } + }); + + it('should find missing recurrence patterns', () => { + const pattern: SCISO8601Date = moment('2021-09-01T10:00').toISOString(); + + expect(findRRules([pattern])).toEqual([pattern]); + }); + + it('should find mixed recurrence patterns', () => { + const singlePattern: SCISO8601Date = + moment('2021-09-01T09:00').toISOString(); + + const weeklyPattern: RRule = { + freq: 'week', + interval: 1, + from: moment('2021-09-03T10:00').toISOString(), + until: moment('2021-09-03T10:00').add(4, 'weeks').toISOString(), + }; + + expect( + findRRules(shuffle([singlePattern, ...expandRRule(weeklyPattern)])), + ).toEqual([singlePattern, weeklyPattern]); + }); +}); diff --git a/src/app/modules/calendar/ical/ical.ts b/src/app/modules/calendar/ical/ical.ts new file mode 100644 index 00000000..cb2263f1 --- /dev/null +++ b/src/app/modules/calendar/ical/ical.ts @@ -0,0 +1,414 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +import { + SCDateSeries, + SCISO8601Date, + SCISO8601Duration, + SCThingTranslator, + SCThingWithCategories, + SCUuid, +} from '@openstapps/core'; +import { + difference, + flatMap, + isObject, + last, + mapValues, + minBy, + size, +} from 'lodash-es'; +import moment, {unitOfTime} from 'moment'; + +export interface ICalEvent { + name?: string; + uuid: SCUuid; + categories?: string[]; + description?: string; + cancelled?: boolean; + recurrenceId?: SCISO8601Date; + geo?: string; + /** + * The sequence index if the series had to be split into multiple rrules + */ + recurrenceSequence?: number; + recurrenceSequenceAmount?: number; + rrule?: RRule; + dates?: SCISO8601Date[]; + exceptionDates?: SCISO8601Date[]; + start: SCISO8601Date; + sequence?: number; + duration?: SCISO8601Duration; + url?: string; +} + +export type ICalKeyValuePair = `${Uppercase}${':' | '='}${string}`; + +export type ICalLike = ICalKeyValuePair[]; + +/** + * + */ +function timeDist( + current: SCISO8601Date, + next: SCISO8601Date | undefined, + recurrence: unitOfTime.Diff, +): number | undefined { + if (!next) { + return undefined; + } + + const diff = moment(next).diff(moment(current), recurrence, true); + + return Math.floor(diff) === diff ? diff : undefined; +} + +export interface RRule { + freq: unitOfTime.Diff; // 'SECONDLY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'; + interval: number; + from: SCISO8601Date; + until: SCISO8601Date; +} + +type Optional = Pick, K> & Omit; + +export interface MergedRRule { + rrule?: RRule; + exceptions?: SCISO8601Date[]; + date?: SCISO8601Date; +} + +/** + * Merge compatible RRules to a single RRule with exceptions + */ +export function mergeRRules( + rules: Array, + allowExceptions = true, +): MergedRRule[] { + if (!allowExceptions) + return rules.map(it => (typeof it === 'string' ? {date: it} : {rrule: it})); + /*map(groupBy(rules, it => `${it.freq}@${it.interval}`), it => { + + });*/ + + return rules.map(it => + typeof it === 'string' ? {date: it} : {rrule: it}, + ) /* TODO */; +} + +/** + * Find RRules in a list of dates + */ +export function findRRules( + dates: SCISO8601Date[], +): Array { + const sorted = dates.sort((a, b) => moment(a).unix() - moment(b).unix()); + + const output: Optional[] = [ + { + from: sorted[0], + until: sorted[0], + interval: -1, + }, + ]; + + for (let i = 0; i < sorted.length; i++) { + const current = sorted[i]; + const next = sorted[i + 1] as SCISO8601Date | undefined; + const element = last(output); + + const units: unitOfTime.Diff[] = element?.freq + ? [element.freq] + : ['day', 'week', 'month', 'year']; + const freq = minBy( + units.map(recurrence => ({ + recurrence: recurrence, + dist: timeDist(current, next, recurrence), + })), + it => it.dist, + )?.recurrence; + const interval = freq ? timeDist(current, next, freq) : undefined; + + if (element?.interval === -1) { + element.freq = freq; + element.interval = interval ?? -1; + } + + if (!freq || element?.freq !== freq || element.interval !== interval) { + if (element) { + element.until = current; + } + + if (next) { + output.push({ + from: next, + until: next, + interval: -1, + }); + } + } else { + element.until = current; + } + } + + return output.map(it => (it.freq ? (it as RRule) : it.from)); +} + +/** + * + */ +export function strikethrough(text: string): string { + return `\u274C ${[...text].join('\u0336')}\u0336`; +} + +/** + * + */ +function getICalData( + dateSeries: SCDateSeries, + translator: SCThingTranslator, +): Pick { + const translated = translator.translatedAccess(dateSeries); + + return { + name: translated.event()?.name, + uuid: dateSeries.uid, + categories: [ + 'stapps', + ...((translated.event() as SCThingWithCategories) + ?.categories ?? []), + ], + description: translated.event()?.description ?? translated.description(), + geo: translated.inPlace()?.name, + }; +} + +export interface ToICalOptions { + allowRRuleExceptions?: boolean; + excludeCancelledEvents?: boolean; +} + +/** + * + */ +export function toICal( + dateSeries: SCDateSeries, + translator: SCThingTranslator, + options: ToICalOptions = {}, +): ICalEvent[] { + const rrules = findRRules( + options.excludeCancelledEvents + ? difference(dateSeries.dates, dateSeries.exceptions ?? []) + : dateSeries.dates, + ); + + return mergeRRules(rrules, options.allowRRuleExceptions).map( + (it, i, array) => ({ + ...getICalData(dateSeries, translator), + dates: dateSeries.dates, + rrule: it.rrule, + recurrenceSequence: array.length > 1 ? i + 1 : undefined, + recurrenceSequenceAmount: array.length > 1 ? array.length : undefined, + exceptionDates: it.exceptions, + start: it.rrule?.from ?? it.date ?? dateSeries.dates[0], + sequence: 0, + duration: dateSeries.duration, + }), + ); +} + +/** + * + */ +export function toICalUpdates( + dateSeries: SCDateSeries, + translator: SCThingTranslator, +): ICalEvent[] { + return ( + dateSeries.exceptions?.map(exception => ({ + ...getICalData(dateSeries, translator), + sequence: 1, + recurrenceId: exception, + cancelled: true, + start: exception, + })) ?? [] + ); +} + +/** + * Convert an ISO8601 date to a string in the format YYYYMMDDTHHMMSSZ + */ +export function iso8601ToICalDateTime( + date: T, +): T extends SCISO8601Date ? string : undefined { + return ( + date ? `${moment(date).utc().format('YYYYMMDDTHHmmss')}Z` : undefined + ) as never; +} + +/** + * Convert an ISO8601 date to a string in the format YYYYMMDD + */ +export function iso8601ToICalDate(date: SCISO8601Date): string { + return `${moment(date).utc().format('YYYYMMDD')}`; +} + +/** + * Recursively stringify all linebreaks to \n strings + */ +function stringifyLinebreaks( + value: T, +): T { + if (typeof value === 'string') { + return value.replace(/\r?\n|\r/g, '\\n') as T; + } + if (Array.isArray(value)) { + return value.map(stringifyLinebreaks) as T; + } + if (isObject(value)) { + return mapValues(value, stringifyLinebreaks) as T; + } + return value; +} + +/** + * Sanitize an ICal object to not contain line breaks and convert dates to iCal format + */ +export function normalizeICalDates(iCal: ICalEvent): ICalEvent { + return { + ...iCal, + dates: iCal.dates?.filter(it => it !== iCal.start).map(iso8601ToICalDate), + exceptionDates: iCal.exceptionDates?.map(iso8601ToICalDate), + start: iso8601ToICalDateTime(iCal.start), + recurrenceId: iso8601ToICalDateTime(iCal.recurrenceId), + }; +} + +const REPEAT_FREQUENCIES: Partial> = { + day: 'DAILY', + week: 'WEEKLY', + month: 'MONTHLY', + year: 'YEARLY', +}; + +/** + * + */ +export function serializeICalLike(iCal: ICalLike): string { + return iCal.map(stringifyLinebreaks).join('\r\n'); +} + +/** + * Removes all strings that are either undefined or end with 'undefined' + */ +function withoutNullishStrings( + array: Array, +): T[] { + return array.filter(it => it && !it.endsWith('undefined')) as T[]; +} + +/** + * + */ +export function serializeRRule(rrule?: RRule): string | undefined { + return rrule + ? `FREQ=${ + REPEAT_FREQUENCIES[rrule.freq ?? 's'] + };UNTIL=${iso8601ToICalDateTime(rrule.until)};INTERVAL=${rrule.interval}` + : undefined; +} + +/** + * Convert an iCal event to a string + */ +export function serializeICalEvent(iCal: ICalEvent): ICalLike { + const normalized = normalizeICalDates(iCal); + + return withoutNullishStrings([ + 'BEGIN:VEVENT', + `DTSTART:${normalized.start}`, + `DURATION:${normalized.duration}`, + `DTSTAMP:${moment().utc().format('YYYYMMDDTHHmmss')}Z`, + `UID:${normalized.uuid}`, + `RECURRENCE-ID:${normalized.recurrenceId}`, + `CATEGORIES:${normalized.categories?.join(',')}`, + `SUMMARY:${normalized.name}`, + `DESCRIPTION:${normalized.description}`, + `STATUS:${normalized.cancelled === true ? 'CANCELLED' : 'CONFIRMED'}`, + `URL:${normalized.url}`, + // `RDATE;VALUE=DATE:${normalized.dates.join(',')}`, + size(normalized.exceptionDates) > 0 + ? `EXDATE;VALUE=DATE:${normalized.exceptionDates?.join(',')}` + : undefined, + `RRULE:${serializeRRule(normalized.rrule)}`, + 'END:VEVENT', + ]); +} + +/** + * Convert an iCal object to a string + */ +export function serializeICal(iCal: ICalEvent[]): string { + return serializeICalLike([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//StApps//NONSGML StApps Calendar//EN', + 'NAME:StApps', + 'X-WR-CALNAME:StApps', + 'X-WR-CALDESC:StApps Calendar', + 'X-WR-TIMEZONE:Europe/Berlin', + 'LOCATION;LANGUAGE=en:Germany', + 'CALSCALE:GREGORIAN', + 'COLOR:#FF0000', + 'METHOD:PUBLISH', + ...flatMap(iCal, serializeICalEvent), + 'END:VCALENDAR', + ]); +} + +/** + * Get transform date series for purpose of native calendar export + */ +export function getNativeCalendarExport( + dateSeries: SCDateSeries[], + translator: SCThingTranslator, +): ICalEvent[] { + return flatMap(dateSeries, event => + toICal(event, translator, { + allowRRuleExceptions: false, + excludeCancelledEvents: true, + }), + ); +} + +/** + * Get transform date series for purpose of iCal file export + */ +export function getICalExport( + dateSeries: SCDateSeries[], + translator: SCThingTranslator, + includeCancelled: boolean, +): ICalEvent[] { + return [ + ...flatMap(dateSeries, event => + toICal(event, translator, { + allowRRuleExceptions: false, + excludeCancelledEvents: !includeCancelled, + }), + ), + ...(includeCancelled + ? flatMap(dateSeries, event => toICalUpdates(event, translator)) + : []), + ]; +} diff --git a/src/app/modules/calendar/new-share.ts b/src/app/modules/calendar/new-share.ts new file mode 100644 index 00000000..1e1d5e34 --- /dev/null +++ b/src/app/modules/calendar/new-share.ts @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +export interface NewShareData { + files?: File[]; + title?: string; + text?: string; + url?: string; +} + +// web share api is relatively new +// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share +export interface NewShareNavigator { + canShare: (options: NewShareData) => boolean; + share: (options: NewShareData) => Promise; +} diff --git a/src/app/modules/schedule/schedule.provider.ts b/src/app/modules/calendar/schedule.provider.ts similarity index 62% rename from src/app/modules/schedule/schedule.provider.ts rename to src/app/modules/calendar/schedule.provider.ts index 0c88da52..186ac1fd 100644 --- a/src/app/modules/schedule/schedule.provider.ts +++ b/src/app/modules/calendar/schedule.provider.ts @@ -24,8 +24,54 @@ import { SCThingType, SCUuid, } from '@openstapps/core'; -import {BehaviorSubject, Subscription} from 'rxjs'; +import {BehaviorSubject, Observable, Subscription} from 'rxjs'; import {DataProvider} from '../data/data.provider'; +import {map} from 'rxjs/operators'; +import {pick} from 'lodash-es'; +import {DateFormatPipe, DurationPipe} from 'ngx-moment'; + +/** + * + */ +export function toDateSeriesRelevantData( + dateSeries: SCDateSeries, +): DateSeriesRelevantData { + return pick(dateSeries, ...dateSeriesRelevantKeys); +} + +export type DateSeriesRelevantKeys = + | 'uid' + | 'dates' + | 'exceptions' + | 'repeatFrequency' + | 'duration'; + +export const dateSeriesRelevantKeys: Array = [ + 'uid', + 'dates', + 'exceptions', + 'repeatFrequency', + 'duration', +]; + +export const formatRelevantKeys: { + [key in DateSeriesRelevantKeys]: ( + value: SCDateSeries[key], + dateFormatter: DateFormatPipe, + durationFormatter: DurationPipe, + ) => string; +} = { + uid: value => value, + dates: (value, dateFormatter) => + `[${value.map(it => dateFormatter.transform(it)).join(', ')}]`, + exceptions: (value, dateFormatter) => + `[${value?.map(it => dateFormatter.transform(it)).join(', ') ?? ''}]`, + repeatFrequency: (value, _, durationFormatter) => + durationFormatter.transform(value), + duration: (value, _, durationFormatter) => durationFormatter.transform(value), +}; + +export type DateSeriesRelevantData = Pick; /** * Provider for app settings @@ -34,14 +80,11 @@ import {DataProvider} from '../data/data.provider'; export class ScheduleProvider implements OnDestroy { // tslint:disable:prefer-function-over-method - /** - * Storage key for event UUIDs - */ - private static uuidStorageKey = 'schedule::event_uuids'; + private static partialEventsStorageKey = 'schedule::partial_events'; - private _uuids$?: BehaviorSubject; + private _partialEvents$?: BehaviorSubject; - private _uuidSubscription?: Subscription; + private _partialEventsSubscription?: Subscription; constructor(private readonly dataProvider: DataProvider) { window.addEventListener('storage', this.storageListener); @@ -70,20 +113,42 @@ export class ScheduleProvider implements OnDestroy { } } + public async restore(uuids: SCUuid[]): Promise { + if (uuids.length === 0) { + return undefined; + } + const dateSeries = (await this.getDateSeries(uuids)).dates; + + this._partialEvents$?.next(dateSeries.map(toDateSeriesRelevantData)); + + return dateSeries; + } + /** * TODO */ - public get uuids$(): BehaviorSubject { - if (!this._uuids$) { - this._uuids$ = new BehaviorSubject( - ScheduleProvider.get(ScheduleProvider.uuidStorageKey), + public get uuids$(): Observable { + return this.partialEvents$.pipe(map(events => events.map(it => it.uid))); + } + + public get partialEvents$(): BehaviorSubject { + if (!this._partialEvents$) { + const data = ScheduleProvider.get( + ScheduleProvider.partialEventsStorageKey, + ); + + this._partialEvents$ = new BehaviorSubject(data ?? []); + this._partialEventsSubscription = this._partialEvents$.subscribe( + result => { + ScheduleProvider.set( + ScheduleProvider.partialEventsStorageKey, + result, + ); + }, ); - this._uuidSubscription = this._uuids$.subscribe(result => { - ScheduleProvider.set(ScheduleProvider.uuidStorageKey, result); - }); } - return this._uuids$; + return this._partialEvents$; } /** @@ -93,9 +158,9 @@ export class ScheduleProvider implements OnDestroy { if ( event.newValue && event.storageArea === localStorage && - event.key === ScheduleProvider.uuidStorageKey + event.key === ScheduleProvider.partialEventsStorageKey ) { - this._uuids$?.next(JSON.parse(event.newValue)); + this._partialEvents$?.next(JSON.parse(event.newValue)); } } @@ -211,7 +276,7 @@ export class ScheduleProvider implements OnDestroy { * TODO */ ngOnDestroy(): void { - this._uuidSubscription?.unsubscribe(); + this._partialEventsSubscription?.unsubscribe(); window.removeEventListener('storage', this.storageListener); } } diff --git a/src/app/modules/data/chips/add-event-popover.component.ts b/src/app/modules/data/chips/add-event-popover.component.ts index f94e863a..890dde64 100644 --- a/src/app/modules/data/chips/add-event-popover.component.ts +++ b/src/app/modules/data/chips/add-event-popover.component.ts @@ -21,8 +21,8 @@ import { OnDestroy, OnInit, } from '@angular/core'; -import {PopoverController} from '@ionic/angular'; -import {SCDateSeries, SCUuid} from '@openstapps/core'; +import {ModalController, PopoverController} from '@ionic/angular'; +import {SCDateSeries} from '@openstapps/core'; import { difference, every, @@ -36,7 +36,14 @@ import { } from 'lodash-es'; import {capitalize, last} from 'lodash-es'; import {Subscription} from 'rxjs'; -import {ScheduleProvider} from '../../schedule/schedule.provider'; +import { + DateSeriesRelevantData, + ScheduleProvider, + toDateSeriesRelevantData, +} from '../../calendar/schedule.provider'; +import {CalendarService} from '../../calendar/calendar.service'; +import {AddEventReviewModalComponent} from '../../calendar/add-event-review-modal.component'; +import {ThingTranslatePipe} from '../../../translation/thing-translate.pipe'; enum Selection { ON = 2, @@ -190,7 +197,7 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy { /** * Uuids */ - uuids: SCUuid[]; + partialDateSeries: DateSeriesRelevantData[]; /** * Uuid Subscription @@ -201,6 +208,9 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy { readonly ref: ChangeDetectorRef, readonly scheduleProvider: ScheduleProvider, readonly popoverController: PopoverController, + readonly calendar: CalendarService, + readonly modalController: ModalController, + readonly thingTranslatePipe: ThingTranslatePipe, ) {} /** @@ -214,16 +224,18 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy { * Init */ ngOnInit() { - this.uuidSubscription = this.scheduleProvider.uuids$.subscribe( + this.uuidSubscription = this.scheduleProvider.partialEvents$.subscribe( async result => { - this.uuids = result; + this.partialDateSeries = result; this.selection = new TreeNode( values( groupBy( sortBy( this.items.map(item => ({ - selected: this.uuids.includes(item.uid), + selected: this.partialDateSeries.some( + it => it.uid === item.uid, + ), item: item, })), it => it.item.repeatFrequency, @@ -237,17 +249,44 @@ export class AddEventPopoverComponent implements OnInit, OnDestroy { ); } + getSelection(): { + selected: DateSeriesRelevantData[]; + unselected: DateSeriesRelevantData[]; + } { + const selection = mapValues( + groupBy(flatMap(this.selection.children, 'children'), 'selected'), + value => value.map(it => toDateSeriesRelevantData(it.item)), + ); + + return {selected: selection.true ?? [], unselected: selection.false ?? []}; + } + + async export() { + const modal = await this.modalController.create({ + component: AddEventReviewModalComponent, + swipeToClose: true, + cssClass: 'add-modal', + componentProps: { + dismissAction: () => { + modal.dismiss(); + }, + dateSeries: this.items, + }, + }); + + await modal.present(); + await modal.onWillDismiss(); + } + /** * On selection change */ async onCommit(save: boolean) { if (save) { - const {false: unselected, true: selected} = mapValues( - groupBy(flatMap(this.selection.children, 'children'), 'selected'), - value => value.map(it => it.item.uid), - ); - this.scheduleProvider.uuids$.next( - union(difference(this.uuids, unselected), selected), + const {selected, unselected} = this.getSelection(); + console.log(selected, unselected); + this.scheduleProvider.partialEvents$.next( + union(difference(this.partialDateSeries, unselected), selected), ); } diff --git a/src/app/modules/data/chips/add-event-popover.html b/src/app/modules/data/chips/add-event-popover.html index fed8d34f..15988206 100644 --- a/src/app/modules/data/chips/add-event-popover.html +++ b/src/app/modules/data/chips/add-event-popover.html @@ -75,4 +75,14 @@ 'ok' | translate }} +
+ + + + +
diff --git a/src/app/modules/data/chips/add-event-popover.scss b/src/app/modules/data/chips/add-event-popover.scss index 351927a2..fd9073d3 100644 --- a/src/app/modules/data/chips/add-event-popover.scss +++ b/src/app/modules/data/chips/add-event-popover.scss @@ -1,3 +1,18 @@ +/*! + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + ::ng-deep ion-item-divider { cursor: pointer; } @@ -5,3 +20,7 @@ .action-buttons { float: right; } + +.download-button { + float: left; +} diff --git a/src/app/modules/data/chips/data/add-event-action-chip.component.ts b/src/app/modules/data/chips/data/add-event-action-chip.component.ts index 679acca8..3888f049 100644 --- a/src/app/modules/data/chips/data/add-event-action-chip.component.ts +++ b/src/app/modules/data/chips/data/add-event-action-chip.component.ts @@ -18,7 +18,7 @@ import {PopoverController} from '@ionic/angular'; import {SCDateSeries, SCThing, SCThingType, SCUuid} from '@openstapps/core'; import {difference, map} from 'lodash-es'; import {Subscription} from 'rxjs'; -import {ScheduleProvider} from '../../../schedule/schedule.provider'; +import {ScheduleProvider} from '../../../calendar/schedule.provider'; import {AddEventPopoverComponent} from '../add-event-popover.component'; import {CoordinatedSearchProvider} from '../../coordinated-search.provider'; import { diff --git a/src/app/modules/data/data.module.ts b/src/app/modules/data/data.module.ts index 30ba6138..17e802db 100644 --- a/src/app/modules/data/data.module.ts +++ b/src/app/modules/data/data.module.ts @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018-2021 StApps + * Copyright (C) 2021 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. @@ -24,7 +24,7 @@ import {MarkdownModule} from 'ngx-markdown'; import {MomentModule} from 'ngx-moment'; import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {MenuModule} from '../menu/menu.module'; -import {ScheduleProvider} from '../schedule/schedule.provider'; +import {ScheduleProvider} from '../calendar/schedule.provider'; import {StorageModule} from '../storage/storage.module'; import {ActionChipListComponent} from './chips/action-chip-list.component'; import {AddEventPopoverComponent} from './chips/add-event-popover.component'; @@ -83,6 +83,7 @@ import {Geolocation} from '@ionic-native/geolocation/ngx'; import {FavoriteButtonComponent} from './elements/favorite-button.component'; import {SimpleDataListComponent} from './list/simple-data-list.component'; import {TitleCardComponent} from './elements/title-card.component'; +import {CalendarService} from '../calendar/calendar.service'; /** * Module for handling data @@ -168,6 +169,7 @@ import {TitleCardComponent} from './elements/title-card.component'; Network, ScheduleProvider, StAppsWebHttpClient, + CalendarService, ], exports: [ DataDetailComponent, diff --git a/src/app/modules/schedule/page/calendar-view.component.ts b/src/app/modules/schedule/page/calendar-view.component.ts index b16a74b2..29d63563 100644 --- a/src/app/modules/schedule/page/calendar-view.component.ts +++ b/src/app/modules/schedule/page/calendar-view.component.ts @@ -25,7 +25,7 @@ import { materialManualFade, materialSharedAxisX, } from '../../../animation/material-motion'; -import {ScheduleProvider} from '../schedule.provider'; +import {ScheduleProvider} from '../../calendar/schedule.provider'; import {ScheduleEvent, ScheduleResponsiveBreakpoint} from './schema/schema'; import {SwiperComponent} from 'swiper/angular'; diff --git a/src/app/modules/schedule/page/grid/schedule-card.component.ts b/src/app/modules/schedule/page/grid/schedule-card.component.ts index a52f0918..19c66b01 100644 --- a/src/app/modules/schedule/page/grid/schedule-card.component.ts +++ b/src/app/modules/schedule/page/grid/schedule-card.component.ts @@ -14,7 +14,7 @@ */ import {Component, Input, OnInit} from '@angular/core'; import moment from 'moment'; -import {ScheduleProvider} from '../../schedule.provider'; +import {ScheduleProvider} from '../../../calendar/schedule.provider'; import {ScheduleEvent} from '../schema/schema'; /** @@ -87,9 +87,9 @@ export class ScheduleCardComponent implements OnInit { */ removeEvent(): false { if (confirm('Remove event?')) { - this.scheduleProvider.uuids$.next( - this.scheduleProvider.uuids$.value.filter( - it => it !== this.scheduleEvent.dateSeries.uid, + this.scheduleProvider.partialEvents$.next( + this.scheduleProvider.partialEvents$.value.filter( + it => it.uid !== this.scheduleEvent.dateSeries.uid, ), ); } diff --git a/src/app/modules/schedule/page/grid/schedule-day.component.ts b/src/app/modules/schedule/page/grid/schedule-day.component.ts index 25bbde16..77b8e736 100644 --- a/src/app/modules/schedule/page/grid/schedule-day.component.ts +++ b/src/app/modules/schedule/page/grid/schedule-day.component.ts @@ -15,7 +15,7 @@ import {Component, Input} from '@angular/core'; import moment from 'moment'; import {Range, ScheduleEvent} from '../schema/schema'; -import {ScheduleProvider} from '../../schedule.provider'; +import {ScheduleProvider} from '../../../calendar/schedule.provider'; import {SCISO8601Duration, SCUuid} from '@openstapps/core'; import {materialFade} from '../../../../animation/material-motion'; diff --git a/src/app/modules/schedule/page/schedule-single-events.component.ts b/src/app/modules/schedule/page/schedule-single-events.component.ts index c3b8901d..ceae71ed 100644 --- a/src/app/modules/schedule/page/schedule-single-events.component.ts +++ b/src/app/modules/schedule/page/schedule-single-events.component.ts @@ -18,7 +18,7 @@ import {flatMap, groupBy, isNil, omit, sortBy} from 'lodash-es'; import moment from 'moment'; import {Subscription} from 'rxjs'; import {materialFade} from '../../../animation/material-motion'; -import {ScheduleProvider} from '../schedule.provider'; +import {ScheduleProvider} from '../../calendar/schedule.provider'; import {ScheduleEvent} from './schema/schema'; /** diff --git a/src/app/modules/schedule/page/schedule-view.component.ts b/src/app/modules/schedule/page/schedule-view.component.ts index af08b8cc..f617986f 100644 --- a/src/app/modules/schedule/page/schedule-view.component.ts +++ b/src/app/modules/schedule/page/schedule-view.component.ts @@ -22,7 +22,7 @@ import { materialManualFade, materialSharedAxisX, } from '../../../animation/material-motion'; -import {ScheduleProvider} from '../schedule.provider'; +import {ScheduleProvider} from '../../calendar/schedule.provider'; import {CalendarViewComponent} from './calendar-view.component'; import {SwiperComponent} from 'swiper/angular'; diff --git a/src/app/modules/schedule/schedule.module.ts b/src/app/modules/schedule/schedule.module.ts index 032e8301..341a903b 100644 --- a/src/app/modules/schedule/schedule.module.ts +++ b/src/app/modules/schedule/schedule.module.ts @@ -30,11 +30,12 @@ import {ModalEventCreatorComponent} from './page/modal/modal-event-creator.compo import {SchedulePageComponent} from './page/schedule-page.component'; import {ScheduleSingleEventsComponent} from './page/schedule-single-events.component'; import {ScheduleViewComponent} from './page/schedule-view.component'; -import {ScheduleProvider} from './schedule.provider'; +import {ScheduleProvider} from '../calendar/schedule.provider'; import {SwiperModule} from 'swiper/angular'; import {ScheduleDayComponent} from './page/grid/schedule-day.component'; import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {InfiniteSwiperComponent} from './page/grid/infinite-swiper.component'; +import {FileOpener} from '@ionic-native/file-opener/ngx'; const settingsRoutes: Routes = [ {path: 'schedule', redirectTo: 'schedule/calendar/now'}, @@ -72,6 +73,6 @@ const settingsRoutes: Routes = [ UtilModule, ThingTranslateModule, ], - providers: [ScheduleProvider, DataProvider, DateFormatPipe], + providers: [ScheduleProvider, DataProvider, DateFormatPipe, FileOpener], }) export class ScheduleModule {} diff --git a/src/app/modules/settings/page/calendar-sync-settings-keys.ts b/src/app/modules/settings/page/calendar-sync-settings-keys.ts new file mode 100644 index 00000000..95258c58 --- /dev/null +++ b/src/app/modules/settings/page/calendar-sync-settings-keys.ts @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public Licens for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +export const CALENDAR_SYNC_SETTINGS_KEY = 'calendarSettings'; +export const CALENDAR_SYNC_ENABLED_KEY = 'sync'; +export const CALENDAR_NOTIFICATIONS_ENABLED_KEY = 'notifications'; +export type CALENDAR_SYNC_KEYS = + | typeof CALENDAR_SYNC_ENABLED_KEY + | typeof CALENDAR_NOTIFICATIONS_ENABLED_KEY; diff --git a/src/app/modules/settings/page/calendar-sync-settings.component.ts b/src/app/modules/settings/page/calendar-sync-settings.component.ts new file mode 100644 index 00000000..e324ab6e --- /dev/null +++ b/src/app/modules/settings/page/calendar-sync-settings.component.ts @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2021 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {Component, OnInit} from '@angular/core'; +import {AddEventReviewModalComponent} from '../../calendar/add-event-review-modal.component'; +import {ModalController} from '@ionic/angular'; +import {ScheduleProvider} from '../../calendar/schedule.provider'; +import {map} from 'lodash-es'; +import {Directory, Encoding, Filesystem} from '@capacitor/filesystem'; +import {Share} from '@capacitor/share'; +import {Device} from '@capacitor/device'; +import {Dialog} from '@capacitor/dialog'; +import {SCUuid} from '@openstapps/core'; +import {TranslateService} from '@ngx-translate/core'; +import {StorageProvider} from '../../storage/storage.provider'; +import {ScheduleSyncService} from '../../background/schedule/schedule-sync.service'; +import {CalendarService} from '../../calendar/calendar.service'; +import {getNativeCalendarExport} from '../../calendar/ical/ical'; +import {ThingTranslateService} from '../../../translation/thing-translate.service'; +import { + CALENDAR_NOTIFICATIONS_ENABLED_KEY, + CALENDAR_SYNC_ENABLED_KEY, + CALENDAR_SYNC_KEYS, + CALENDAR_SYNC_SETTINGS_KEY, +} from './calendar-sync-settings-keys'; + +@Component({ + selector: 'calendar-sync-settings', + templateUrl: 'calendar-sync-settings.html', + styleUrls: ['calendar-sync-settings.scss'], +}) +export class CalendarSyncSettingsComponent implements OnInit { + isWeb = true; + + syncEnabled = false; + + notificationsEnabled = false; + + constructor( + readonly modalController: ModalController, + readonly scheduleProvider: ScheduleProvider, + readonly translator: TranslateService, + readonly thingTranslator: ThingTranslateService, + readonly storageProvider: StorageProvider, + readonly scheduleSyncService: ScheduleSyncService, + readonly calendarService: CalendarService, + ) {} + + ngOnInit() { + Device.getInfo().then(it => { + this.isWeb = it.platform === 'web'; + }); + + this.getSetting(CALENDAR_SYNC_ENABLED_KEY).then( + it => (this.syncEnabled = it), + ); + this.getSetting(CALENDAR_NOTIFICATIONS_ENABLED_KEY).then( + it => (this.notificationsEnabled = it), + ); + } + + async getSetting(key: CALENDAR_SYNC_KEYS) { + return (await this.storageProvider.get( + `${CALENDAR_SYNC_SETTINGS_KEY}.${key}`, + )) as boolean; + } + + async syncCalendar(sync: boolean) { + this.syncEnabled = sync; + + if (sync) { + const uuids = this.scheduleProvider.partialEvents$.value.map( + it => it.uid, + ); + const dateSeries = (await this.scheduleProvider.getDateSeries(uuids)) + .dates; + + await this.calendarService.syncEvents( + getNativeCalendarExport(dateSeries, this.thingTranslator.translator), + ); + } else { + await this.calendarService.purge(); + } + } + + async setSetting(settings: Partial>) { + await Promise.all( + map(settings, (setting, key) => + this.storageProvider.put( + `${CALENDAR_SYNC_SETTINGS_KEY}.${key}`, + setting, + ), + ), + ); + + return this.scheduleSyncService.enable(); + } + + async export() { + const uuids = this.scheduleProvider.partialEvents$.value.map(it => it.uid); + const dateSeries = (await this.scheduleProvider.getDateSeries(uuids)).dates; + + const modal = await this.modalController.create({ + component: AddEventReviewModalComponent, + swipeToClose: true, + cssClass: 'add-modal', + componentProps: { + dismissAction: () => { + modal.dismiss(); + }, + dateSeries: dateSeries, + }, + }); + + await modal.present(); + await modal.onWillDismiss(); + } + + async restore(event: Event) { + // @ts-expect-error files do actually exist + const file = event.target?.files[0] as File; + const uuids = JSON.parse(await file.text()) as SCUuid[] | unknown; + if (!Array.isArray(uuids) || uuids.some(it => typeof it !== 'string')) { + return Dialog.alert({ + title: this.translator.instant( + 'settings.calendar.export.dialogs.restore.rejectFile.title', + ), + message: this.translator.instant( + 'settings.calendar.export.dialogs.restore.rejectFile.message', + ), + }); + } + const dateSeries = await this.scheduleProvider.restore(uuids); + return dateSeries + ? Dialog.confirm({ + title: this.translator.instant( + 'settings.calendar.export.dialogs.restore.success.title', + ), + message: this.translator.instant( + 'settings.calendar.export.dialogs.restore.success.message', + ), + }) + : Dialog.alert({ + title: this.translator.instant( + 'settings.calendar.export.dialogs.restore.error.title', + ), + message: this.translator.instant( + 'settings.calendar.export.dialogs.restore.error.message', + ), + }); + } + + translateWithDefault(key: string, defaultValue: string) { + const out = this.translator.instant(key); + return out === key ? defaultValue : out; + } + + async backup() { + const uuids = JSON.stringify( + this.scheduleProvider.partialEvents$.value.map(it => it.uid), + ); + + const fileName = `${this.translator.instant( + 'settings.calendar.export.fileName', + )}.json`; + const info = await Device.getInfo(); + if (info.platform === 'web') { + const blob = new Blob([uuids], {type: 'application/json'}); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + window.URL.revokeObjectURL(url); + } else { + const result = await Filesystem.writeFile({ + path: fileName, + data: uuids, + encoding: Encoding.UTF8, + directory: Directory.Cache, + }); + + await Share.share({ + title: this.translator.instant( + 'settings.calendar.export.dialogs.backup.save.title', + ), + text: this.translator.instant( + 'settings.calendar.export.dialogs.backup.save.message', + ), + url: result.uri, + dialogTitle: this.translator.instant( + 'settings.calendar.export.dialogs.backup.save.title', + ), + }); + } + } +} diff --git a/src/app/modules/settings/page/calendar-sync-settings.html b/src/app/modules/settings/page/calendar-sync-settings.html new file mode 100644 index 00000000..5bcd6d58 --- /dev/null +++ b/src/app/modules/settings/page/calendar-sync-settings.html @@ -0,0 +1,120 @@ + + + + + {{ + 'settings.calendar.title' | translate + }} + + + + + + + {{ + 'settings.calendar.sync.title' | translate + }} + + + + + {{ + 'settings.calendar.sync.syncWithCalendar' | translate + }} + + + + + {{ + 'settings.calendar.sync.eventNotifications' | translate + }} + + + + Sync Now + + + + + {{ + 'settings.calendar.sync.unavailableWeb' | translate + }} + + + + + + {{ + 'settings.calendar.export.title' | translate + }} + + + + {{ + 'settings.calendar.export.exportEvents' | translate + }} + + + + + + {{ + 'settings.calendar.export.backup' | translate + }} + + + + {{ + 'settings.calendar.export.restore' | translate + }} + + + + + + + + + diff --git a/src/app/modules/settings/page/calendar-sync-settings.scss b/src/app/modules/settings/page/calendar-sync-settings.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/settings/page/settings-page.html b/src/app/modules/settings/page/settings-page.html index e2f10366..6d3adb31 100644 --- a/src/app/modules/settings/page/settings-page.html +++ b/src/app/modules/settings/page/settings-page.html @@ -16,9 +16,11 @@ {{ 'categories[0]' | thingTranslate - : settingsCache[categoryKey]?.settings[ - objectKeys(settingsCache[categoryKey]?.settings)[0] - ] + : $any( + settingsCache[categoryKey]?.settings[ + objectKeys(settingsCache[categoryKey]?.settings)[0] + ] + ) | titlecase }} @@ -31,6 +33,9 @@ > + + + { let configProviderSpy: jasmine.SpyObj; let settingsProvider: SettingsProvider; let storageProviderSpy: jasmine.SpyObj; + let scheduleSyncServiceSpy: jasmine.SpyObj; beforeEach(async () => { const storageProviderMethodSpy = jasmine.createSpyObj('StorageProvider', [ @@ -42,6 +44,10 @@ describe('SettingsProvider', () => { const configProviderMethodSpy = jasmine.createSpyObj('ConfigProvider', [ 'getValue', ]); + scheduleSyncServiceSpy = jasmine.createSpyObj('ScheduleSyncService', [ + 'getDifferences', + 'postDifferencesNotification', + ]); TestBed.configureTestingModule({ imports: [], @@ -55,6 +61,10 @@ describe('SettingsProvider', () => { provide: ConfigProvider, useValue: configProviderMethodSpy, }, + { + provide: ScheduleSyncService, + useValue: scheduleSyncServiceSpy, + }, ], }); configProviderSpy = TestBed.get(ConfigProvider); diff --git a/src/assets/i18n/de.json b/src/assets/i18n/de.json index 85ddc7cf..f5d5ea0b 100644 --- a/src/assets/i18n/de.json +++ b/src/assets/i18n/de.json @@ -1,6 +1,8 @@ { - "ok": "ok", - "abort": "abbrechen", + "ok": "Ok", + "abort": "Abbrechen", + "export": "Exportieren", + "share": "Teilen", "modal": { "DISMISS": "Schließen" }, @@ -276,6 +278,37 @@ } }, "schedule": { + "toCalendar": { + "reviewModal": { + "TITLE": "Termine bestätigen", + "EXPORT": "Zum Kalender exportieren", + "DOWNLOAD": "Termine herunterladen", + "INCLUDE_CANCELLED": "Abgesagte Termine einbeziehen", + "shareData": { + "TITLE": "Kalender", + "TEXT": "Enthält Termine aus der StApps App", + "FILE_NAME": "kalender" + }, + "dialogs": { + "toCalendarConfirm": { + "TITLE": "Zum Kalender exportieren", + "DESCRIPTION": "Termine zum Gerätekalender exportieren?" + }, + "cannotShare": { + "TITLE": "Teilen nicht möglich", + "DESCRIPTION": "Die Teilen Funktionalität wird von diesem Browser nicht unterstützt." + }, + "unsupportedFileType": { + "TITLE": "Dateityp nicht unterstützt", + "DESCRIPTION": "Kalenderdateien können von diesem Browser aus nicht geteilt werden." + }, + "failedShare": { + "TITLE": "Fehler beim Teilen", + "DESCRIPTION": "Unbekannter Fehler beim Teilen." + } + } + } + }, "recurring": "Stundenplan", "calendar": "Kalender", "single": "Einzeltermine", @@ -316,6 +349,44 @@ "resetAlert.buttonCancel": "Abbrechen", "resetToast.message": "Einstellungen wurden zurückgesetzt", "title": "Einstellungen", - "resetSettings": "Einstellungen zurücksetzen" + "resetSettings": "Einstellungen zurücksetzen", + "calendar": { + "title": "Kalender", + "sync": { + "title": "Synchronisierung", + "unavailableWeb": "Synchronisierung mit dem Gerätekalender wird im Web nicht unterstützt.", + "syncWithCalendar": "Mit Gerätekalender synchronisieren", + "eventNotifications": "Bei Terminänderungen benachrichtigen" + }, + "export": { + "title": "Export", + "exportEvents": "Alle Termine exportieren", + "backup": "Backup", + "restore": "Wiederherstellen", + "fileName": "kalender_backup", + "dialogs": { + "backup": { + "save": { + "title": "StApps Kalender Backup", + "message": "Enthält eine vollständige Terminliste des Kalenders. Diese Datei kann zum wiederherstellen des Kalenders wieder importiert werden." + } + }, + "restore": { + "rejectFile": { + "title": "Ungültige Datei", + "message": "Die ausgewählte Datei ist keine gültige StApps Kalender Backup-Datei." + }, + "success": { + "title": "Wiederherstellung erfolgreich", + "message": "Der Kalender wurde erfolgreich wiederhergestellt." + }, + "error": { + "title": "Wiederherstellung fehlgeschlagen", + "message": "Beim Wiederherstellen des Kalenders ist ein Fehler aufgetreten." + } + } + } + } + } } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 77628d27..24c6c464 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1,8 +1,10 @@ { - "ok": "ok", - "abort": "abort", + "ok": "Ok", + "abort": "Abort", + "export": "Export", + "share": "Share", "modal": { - "DISMISS": "close" + "DISMISS": "Close" }, "app": { "ui": { @@ -276,6 +278,37 @@ } }, "schedule": { + "toCalendar": { + "reviewModal": { + "TITLE": "Confirm events", + "EXPORT": "Export to calendar", + "DOWNLOAD": "Download events", + "INCLUDE_CANCELLED": "Include cancelled events", + "shareData": { + "TITLE": "Calendar", + "TEXT": "Contains events from StApps", + "FILE_NAME": "calendar" + }, + "dialogs": { + "toCalendarConfirm": { + "TITLE": "Export to calendar", + "DESCRIPTION": "Do you want to export the selected events to your device calendar?" + }, + "cannotShare": { + "TITLE": "Sharing failed", + "DESCRIPTION": "Your browser does not support sharing." + }, + "unsupportedFileType": { + "TITLE": "Unsupported file type", + "DESCRIPTION": "Your browser does not support sharing calendar files." + }, + "failedShare": { + "TITLE": "Sharing failed", + "DESCRIPTION": "Sharing failed. Please try again." + } + } + } + }, "recurring": "Recurring", "calendar": "Calendar", "single": "Single Events", @@ -317,6 +350,44 @@ "resetAlert.buttonCancel": "cancel", "resetToast.message": "Settings reset", "title": "Settings", - "resetSettings": "Reset Settings" + "resetSettings": "Reset Settings", + "calendar": { + "title": "Calendar", + "sync": { + "title": "Sync", + "unavailableWeb": "Sync with device calendar is not available in web version.", + "syncWithCalendar": "Sync with device calendar", + "eventNotifications": "Event update notifications" + }, + "export": { + "title": "Export", + "exportEvents": "Export all events", + "backup": "Backup", + "restore": "Restore", + "fileName": "calendar_backup", + "dialogs": { + "backup": { + "save": { + "title": "StApps Calendar Backup", + "message": "Contains a list of all events in the calendar. You can import this file to restore your calendar." + } + }, + "restore": { + "rejectFile": { + "title": "Invalid file", + "message": "The file you selected is not a valid backup file." + }, + "success": { + "title": "Restore successful", + "message": "Calendar has been restored successfully." + }, + "error": { + "title": "Restore failed", + "message": "Backup file is corrupted." + } + } + } + } + } } }