From d77c9aed492d175dc6d505dce127023f50547037 Mon Sep 17 00:00:00 2001 From: Preston Date: Sun, 6 Sep 2020 18:12:25 -0400 Subject: [PATCH] Add functionality to import and export subscriptions / history --- package-lock.json | 159 +++- package.json | 10 +- .../components/data-settings/data-settings.js | 782 ++++++++++++++++++ .../data-settings/data-settings.sass | 1 + .../data-settings/data-settings.vue | 44 + .../ft-profile-selector.css | 4 +- .../privacy-settings/privacy-settings.js | 33 + .../privacy-settings/privacy-settings.vue | 13 + src/renderer/views/Settings/Settings.js | 13 +- src/renderer/views/Settings/Settings.vue | 1 + src/renderer/views/Trending/Trending.js | 9 +- static/locales/en-US.yaml | 32 +- 12 files changed, 1036 insertions(+), 65 deletions(-) create mode 100644 src/renderer/components/data-settings/data-settings.js create mode 100644 src/renderer/components/data-settings/data-settings.sass create mode 100644 src/renderer/components/data-settings/data-settings.vue diff --git a/package-lock.json b/package-lock.json index b193785e..76b25d09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,13 +31,13 @@ } }, "@babel/core": { - "version": "7.11.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.5.tgz", - "integrity": "sha512-fsEANVOcZHzrsV6dMVWqpSeXClq3lNbYrfFGme6DE25FQWe7pyeYpXyx9guqUnpy466JLzZ8z4uwSr2iv60V5Q==", + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz", + "integrity": "sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==", "dev": true, "requires": { "@babel/code-frame": "^7.10.4", - "@babel/generator": "^7.11.5", + "@babel/generator": "^7.11.6", "@babel/helper-module-transforms": "^7.11.0", "@babel/helpers": "^7.10.4", "@babel/parser": "^7.11.5", @@ -51,7 +51,7 @@ "lodash": "^4.17.19", "resolve": "^1.3.2", "semver": "^5.4.1", - "source-map": "^0.6.1" + "source-map": "^0.5.0" }, "dependencies": { "@babel/code-frame": { @@ -64,14 +64,14 @@ } }, "@babel/generator": { - "version": "7.11.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.5.tgz", - "integrity": "sha512-9UqHWJ4IwRTy4l0o8gq2ef8ws8UPzvtMkVKjTLAiRmza9p9V6Z+OfuNd9fB1j5Q67F+dVJtPC2sZXI8NM9br4g==", + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz", + "integrity": "sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==", "dev": true, "requires": { "@babel/types": "^7.11.5", "jsesc": "^2.5.1", - "source-map": "^0.6.1" + "source-map": "^0.5.0" } }, "@babel/highlight": { @@ -133,12 +133,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true } } }, @@ -1602,6 +1596,80 @@ } } }, + "@eslint/eslintrc": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.1.3.tgz", + "integrity": "sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "ajv": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz", + "integrity": "sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "globals": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", + "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + } + } + }, "@fortawesome/fontawesome-common-types": { "version": "0.2.30", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.30.tgz", @@ -1624,9 +1692,9 @@ } }, "@fortawesome/vue-fontawesome": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-0.1.10.tgz", - "integrity": "sha512-b2+SLF31h32LSepVcXe+BQ63yvbq5qmTCy4KfFogCYm2bn68H5sDWUnX+U7MBqnM2aeEk9M7xSoqGnu+wSdY6w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.0.tgz", + "integrity": "sha512-N3VKw7KzRfOm8hShUVldpinlm13HpvLBQgT63QS+aCrIRLwjoEUXY5Rcmttbfb6HkzZaeqjLqd/aZCQ53UjQpg==" }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -6956,9 +7024,9 @@ } }, "electron": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-10.1.0.tgz", - "integrity": "sha512-DyS6WhQ59+ZXQsI1EkpsYkOXFt0Xbp+mbxPTJS9A7O21r3JDzaTC+1Jxz7g6J+Sbi9Y7UFdRs0tn/vqhHJx2gA==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-10.1.1.tgz", + "integrity": "sha512-ZJtZHMr17AvvBosuA6XUmpehwAlGM4/n46Mw9BcyD8tpgdI6IQd0X5OU9meE3X3M8Y6Ja2Kr2udTMgtjvot2hA==", "dev": true, "requires": { "@electron/get": "^1.0.1", @@ -7853,12 +7921,13 @@ } }, "eslint": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.7.0.tgz", - "integrity": "sha512-1KUxLzos0ZVsyL81PnRN335nDtQ8/vZUD6uMtWbF+5zDtjKcsklIi78XoE0MVL93QvWTu+E5y44VyyCsOMBrIg==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.8.1.tgz", + "integrity": "sha512-/2rX2pfhyUG0y+A123d0ccXtMm7DV7sH1m3lk9nk2DZ2LReq39FXHueR9xZwshE5MdfSf0xunSaMWRqyIA6M1w==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", + "@eslint/eslintrc": "^0.1.3", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -7868,7 +7937,7 @@ "eslint-scope": "^5.1.0", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^1.3.0", - "espree": "^7.2.0", + "espree": "^7.3.0", "esquery": "^1.2.0", "esutils": "^2.0.2", "file-entry-cache": "^5.0.1", @@ -8081,9 +8150,9 @@ "dev": true }, "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "requires": { "has-flag": "^4.0.0" @@ -8319,12 +8388,12 @@ "dev": true }, "espree": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-7.2.0.tgz", - "integrity": "sha512-H+cQ3+3JYRMEIOl87e7QdHX70ocly5iW4+dttuR8iYSPr/hXKFb+7dBsZ7+u1adC4VrnPlTkv0+OwuPnDop19g==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz", + "integrity": "sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==", "dev": true, "requires": { - "acorn": "^7.3.1", + "acorn": "^7.4.0", "acorn-jsx": "^5.2.0", "eslint-visitor-keys": "^1.3.0" }, @@ -16630,18 +16699,24 @@ } }, "sass-loader": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.0.1.tgz", - "integrity": "sha512-b2PSldKVTS3JcFPHSrEXh3BeAfR7XknGiGCAO5aHruR3Pf3kqLP3Gb2ypXLglRrAzgZkloNxLZ7GXEGDX0hBUQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.0.2.tgz", + "integrity": "sha512-wV6NDUVB8/iEYMalV/+139+vl2LaRFlZGEd5/xmdcdzQcgmis+npyco6NsDTVOlNA3y2NV9Gcz+vHyFMIT+ffg==", "dev": true, "requires": { "klona": "^2.0.3", "loader-utils": "^2.0.0", "neo-async": "^2.6.2", - "schema-utils": "^2.7.0", + "schema-utils": "^2.7.1", "semver": "^7.3.2" }, "dependencies": { + "@types/json-schema": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", + "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==", + "dev": true + }, "ajv": { "version": "6.12.4", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz", @@ -16696,14 +16771,14 @@ "dev": true }, "schema-utils": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", - "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", "dev": true, "requires": { - "@types/json-schema": "^7.0.4", - "ajv": "^6.12.2", - "ajv-keywords": "^3.4.1" + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" } }, "semver": { diff --git a/package.json b/package.json index 24fc7771..a458c3a7 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/free-solid-svg-icons": "^5.14.0", - "@fortawesome/vue-fontawesome": "^0.1.10", + "@fortawesome/vue-fontawesome": "^2.0.0", "@silvermine/videojs-quality-selector": "^1.2.4", "autolinker": "^3.14.1", "bulma-pro": "^0.2.0", @@ -53,7 +53,7 @@ }, "description": "A private YouTube client", "devDependencies": { - "@babel/core": "^7.11.5", + "@babel/core": "^7.11.6", "@babel/plugin-proposal-class-properties": "^7.10.4", "@babel/plugin-proposal-object-rest-spread": "^7.11.0", "@babel/preset-env": "^7.11.5", @@ -66,12 +66,12 @@ "copy-webpack-plugin": "^6.1.0", "css-loader": "^4.2.2", "devtron": "^1.4.0", - "electron": "^10.1.0", + "electron": "^10.1.1", "electron-builder": "^22.8.0", "electron-builder-squirrel-windows": "^22.8.1", "electron-debug": "^3.1.0", "electron-rebuild": "^2.0.1", - "eslint": "^7.7.0", + "eslint": "^7.8.1", "eslint-config-prettier": "^6.11.0", "eslint-config-standard": "^14.1.1", "eslint-plugin-import": "^2.22.0", @@ -90,7 +90,7 @@ "npm-run-all": "^4.1.5", "prettier": "^2.1.1", "sass": "^1.26.10", - "sass-loader": "^10.0.1", + "sass-loader": "^10.0.2", "style-loader": "^1.2.1", "tree-kill": "1.2.2", "typescript": "^4.0.2", diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js new file mode 100644 index 00000000..a31f054c --- /dev/null +++ b/src/renderer/components/data-settings/data-settings.js @@ -0,0 +1,782 @@ +import Vue from 'vue' +import { mapActions, mapMutations } from 'vuex' +import FtCard from '../ft-card/ft-card.vue' +import FtButton from '../ft-button/ft-button.vue' +import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' +import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' +import FtPrompt from '../ft-prompt/ft-prompt.vue' + +import { remote } from 'electron' +import fs from 'fs' +import opmlToJson from 'opml-to-json' +import ytch from 'yt-channel-info' + +const app = remote.app +const dialog = remote.dialog + +export default Vue.extend({ + name: 'DataSettings', + components: { + 'ft-card': FtCard, + 'ft-button': FtButton, + 'ft-toggle-switch': FtToggleSwitch, + 'ft-flex-box': FtFlexBox, + 'ft-prompt': FtPrompt + }, + data: function () { + return { + showImportSubscriptionsPrompt: false, + showExportSubscriptionsPrompt: false, + subscriptionsPromptValues: [ + 'freetube', + 'youtube', + 'newpipe' + ] + } + }, + computed: { + rememberHistory: function () { + return this.$store.getters.getRememberHistory + }, + saveWatchedProgress: function () { + return this.$store.getters.getSaveWatchedProgress + }, + backendPreference: function () { + return this.$store.getters.getBackendPreference + }, + backendFallback: function () { + return this.$store.getters.getBackendFallback + }, + invidiousInstance: function () { + return this.$store.getters.getInvidiousInstance + }, + profileList: function () { + return this.$store.getters.getProfileList + }, + importSubscriptionsPromptNames: function () { + const importFreeTube = this.$t('Settings.Data Settings.Import FreeTube') + const importYouTube = this.$t('Settings.Data Settings.Import YouTube') + const importNewPipe = this.$t('Settings.Data Settings.Import NewPipe') + return [ + `${importFreeTube} (.db)`, + `${importYouTube} (.opml)`, + `${importNewPipe} (.json)` + ] + }, + exportSubscriptionsPromptNames: function () { + const exportFreeTube = this.$t('Settings.Data Settings.Export FreeTube') + const exportYouTube = this.$t('Settings.Data Settings.Export YouTube') + const exportNewPipe = this.$t('Settings.Data Settings.Export NewPipe') + return [ + `${exportFreeTube} (.db)`, + `${exportYouTube} (.opml)`, + `${exportNewPipe} (.json)` + ] + } + }, + methods: { + importSubscriptions: function (option) { + this.showImportSubscriptionsPrompt = false + + if (option === null) { + return + } + + switch (option) { + case 'freetube': + this.importFreeTubeSubscriptions() + break + case 'youtube': + this.importYouTubeSubscriptions() + break + case 'newpipe': + this.importNewPipeSubscriptions() + break + } + }, + + importFreeTubeSubscriptions: function () { + const options = { + properties: ['openFile'], + filters: [ + { + name: 'Database File', + extensions: ['db'] + } + ] + } + + dialog.showOpenDialog(options).then((response) => { + if (response.canceled || response.filePaths.length === 0) { + return + } + + const filePath = response.filePaths[0] + + fs.readFile(filePath, async (err, data) => { + if (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + + let textDecode = new TextDecoder('utf-8').decode(data) + textDecode = textDecode.split('\n') + textDecode.pop() + + textDecode.forEach((data) => { + const profileData = JSON.parse(data) + // We would technically already be done by the time the data is parsed, + // however we want to limit the possibility of malicious data being sent + // to the app, so we'll only grab the data we need here. + + const requiredKeys = [ + '_id', + 'name', + 'bgColor', + 'textColor', + 'subscriptions' + ] + + const profileObject = {} + + Object.keys(profileData).forEach((key) => { + if (!requiredKeys.includes(key)) { + const message = this.$t('Settings.Data Settings.Unknown data key') + this.showToast({ + message: `${message}: ${key}` + }) + } else { + profileObject[key] = profileData[key] + } + }) + + if (Object.keys(profileObject).length < requiredKeys.length) { + const message = this.$t('Settings.Data Settings.Profile object has insufficient data, skipping item') + this.showToast({ + message: message + }) + } else { + this.updateProfile(profileObject) + } + }) + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions and profiles have been successfully imported') + }) + }) + }) + }, + + importYouTubeSubscriptions: function () { + const options = { + properties: ['openFile'], + filters: [ + { + name: 'Database File', + extensions: ['*'] + } + ] + } + + dialog.showOpenDialog(options).then(async (response) => { + if (response.canceled || response.filePaths.length === 0) { + return + } + + const filePath = response.filePaths[0] + + fs.readFile(filePath, async (err, data) => { + if (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + + opmlToJson(data, async (err, json) => { + if (err) { + console.log(err) + const message = this.$t('Settings.Data Settings.Invalid subscriptions file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + + const feedData = json.children[0].children + + if (typeof feedData === 'undefined') { + const message = this.$t('Settings.Data Settings.Invalid subscriptions file') + this.showToast({ + message: message + }) + + return + } + + const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) + const subscriptions = [] + + this.showToast({ + message: this.$t('Settings.Data Settings.This might take a while, please wait') + }) + + this.updateShowProgressBar(true) + this.setProgressBarPercentage(0) + + feedData.forEach(async (channel, index) => { + const channelId = channel.xmlurl.replace('https://www.youtube.com/feeds/videos.xml?channel_id=', '') + let channelInfo + if (this.backendPreference === 'invidious') { + channelInfo = await this.getChannelInfoInvidious(channelId) + } else { + channelInfo = await this.getChannelInfoLocal(channelId) + } + + const subscription = { + id: channelId, + name: channelInfo.author, + thumbnail: channelInfo.authorThumbnails[1].url + } + + subscriptions.push(subscription) + + const progressPercentage = ((subscriptions.length + 1) / feedData.length) * 100 + this.setProgressBarPercentage(progressPercentage) + + if (subscriptions.length === feedData.length) { + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) + this.updateProfile(primaryProfile) + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') + }) + this.updateShowProgressBar(false) + } + }) + }) + }) + }) + }, + + importNewPipeSubscriptions: function () { + const options = { + properties: ['openFile'], + filters: [ + { + name: 'Database File', + extensions: ['json'] + } + ] + } + + dialog.showOpenDialog(options).then(async (response) => { + if (response.canceled || response.filePaths.length === 0) { + return + } + + const filePath = response.filePaths[0] + + fs.readFile(filePath, async (err, data) => { + if (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + + const newPipeData = JSON.parse(data) + + if (typeof newPipeData.subscriptions === 'undefined') { + this.showToast({ + message: this.$t('Settings.Data Settings.Invalid subscriptions file') + }) + + return + } + + const newPipeSubscriptions = newPipeData.subscriptions + + const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) + const subscriptions = [] + + this.showToast({ + message: this.$t('Settings.Data Settings.This might take a while, please wait') + }) + + this.updateShowProgressBar(true) + this.setProgressBarPercentage(0) + + newPipeSubscriptions.forEach(async (channel, index) => { + const channelId = channel.url.replace(/https:\/\/(www\.)?youtube\.com\/channel\//, '') + let channelInfo + if (this.backendPreference === 'invidious') { + channelInfo = await this.getChannelInfoInvidious(channelId) + } else { + channelInfo = await this.getChannelInfoLocal(channelId) + } + + const subscription = { + id: channelId, + name: channelInfo.author, + thumbnail: channelInfo.authorThumbnails[1].url + } + + subscriptions.push(subscription) + + const progressPercentage = ((subscriptions.length + 1) / newPipeSubscriptions.length) * 100 + this.setProgressBarPercentage(progressPercentage) + + if (subscriptions.length === newPipeSubscriptions.length) { + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) + this.updateProfile(primaryProfile) + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') + }) + this.updateShowProgressBar(false) + } + }) + }) + }) + }, + + exportSubscriptions: function (option) { + this.showExportSubscriptionsPrompt = false + + if (option === null) { + return + } + + switch (option) { + case 'freetube': + this.exportFreeTubeSubscriptions() + break + case 'youtube': + this.exportYouTubeSubscriptions() + break + case 'newpipe': + this.exportNewPipeSubscriptions() + break + } + }, + + exportFreeTubeSubscriptions: function () { + const userData = app.getPath('userData') + const subscriptionsDb = `${userData}/profiles.db` + const date = new Date() + let dateMonth = date.getMonth() + 1 + + if (dateMonth < 10) { + dateMonth = '0' + dateMonth + } + + let dateDay = date.getDate() + + if (dateDay < 10) { + dateDay = '0' + dateDay + } + + const dateYear = date.getFullYear() + const exportFileName = 'freetube-subscriptions-' + dateYear + '-' + dateMonth + '-' + dateDay + '.db' + + const options = { + defaultPath: exportFileName, + filters: [ + { + name: 'Database File', + extensions: ['db'] + } + ] + } + + dialog.showSaveDialog(options).then((response) => { + if (response.canceled || response.filePath === '') { + // User canceled the save dialog + return + } + + const filePath = response.filePath + + fs.readFile(subscriptionsDb, (readErr, data) => { + if (readErr) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${readErr}` + }) + return + } + + fs.writeFile(filePath, data, (writeErr) => { + if (writeErr) { + const message = this.$t('Settings.Data Settings.Unable to write file') + this.showToast({ + message: `${message}: ${writeErr}` + }) + return + } + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully exported') + }) + }) + }) + }) + }, + + exportYouTubeSubscriptions: async function () { + const date = new Date() + let dateMonth = date.getMonth() + 1 + + if (dateMonth < 10) { + dateMonth = '0' + dateMonth + } + + let dateDay = date.getDate() + + if (dateDay < 10) { + dateDay = '0' + dateDay + } + + const dateYear = date.getFullYear() + const exportFileName = 'youtube-subscriptions-' + dateYear + '-' + dateMonth + '-' + dateDay + '.opml' + + const options = { + defaultPath: exportFileName, + filters: [ + { + name: 'Database File', + extensions: ['opml'] + } + ] + } + + let opmlData = '' + const endingOpmlString = '' + + let count = 0 + + this.profileList[0].subscriptions.forEach((channel) => { + const channelOpmlString = `` + count++ + opmlData += channelOpmlString + + if (count === this.profileList[0].subscriptions.length) { + opmlData += endingOpmlString + } + }) + + dialog.showSaveDialog(options).then((response) => { + if (response.canceled || response.filePath === '') { + // User canceled the save dialog + return + } + + const filePath = response.filePath + + fs.writeFile(filePath, opmlData, (writeErr) => { + if (writeErr) { + const message = this.$t('Settings.Data Settings.Unable to write file') + this.showToast({ + message: `${message}: ${writeErr}` + }) + return + } + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully exported') + }) + }) + }) + }, + + exportNewPipeSubscriptions: function () { + const date = new Date() + let dateMonth = date.getMonth() + 1 + + if (dateMonth < 10) { + dateMonth = '0' + dateMonth + } + + let dateDay = date.getDate() + + if (dateDay < 10) { + dateDay = '0' + dateDay + } + + const dateYear = date.getFullYear() + const exportFileName = 'newpipe-subscriptions-' + dateYear + '-' + dateMonth + '-' + dateDay + '.json' + + const options = { + defaultPath: exportFileName, + filters: [ + { + name: 'Database File', + extensions: ['json'] + } + ] + } + + const newPipeObject = { + app_version: '0.19.8', + app_version_int: 953, + subscriptions: [] + } + + this.profileList[0].subscriptions.forEach((channel) => { + const channelUrl = `https://www.youtube.com/channel/${channel.id}` + const subscription = { + service_id: 0, + url: channelUrl, + name: channel.name + } + + newPipeObject.subscriptions.push(subscription) + }) + + dialog.showSaveDialog(options).then((response) => { + if (response.canceled || response.filePath === '') { + // User canceled the save dialog + return + } + + const filePath = response.filePath + + fs.writeFile(filePath, JSON.stringify(newPipeObject), (writeErr) => { + if (writeErr) { + const message = this.$t('Settings.Data Settings.Unable to write file') + this.showToast({ + message: `${message}: ${writeErr}` + }) + return + } + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully exported') + }) + }) + }) + }, + + importHistory: function () { + const options = { + properties: ['openFile'], + filters: [ + { + name: 'Database File', + extensions: ['db'] + } + ] + } + + dialog.showOpenDialog(options).then((response) => { + if (response.canceled || response.filePaths.length === 0) { + return + } + + const filePath = response.filePaths[0] + + fs.readFile(filePath, async (err, data) => { + if (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + + let textDecode = new TextDecoder('utf-8').decode(data) + textDecode = textDecode.split('\n') + textDecode.pop() + + textDecode.forEach((history) => { + const historyData = JSON.parse(history) + // We would technically already be done by the time the data is parsed, + // however we want to limit the possibility of malicious data being sent + // to the app, so we'll only grab the data we need here. + const requiredKeys = [ + '_id', + 'author', + 'authorId', + 'description', + 'isLive', + 'lengthSeconds', + 'paid', + 'published', + 'timeWatched', + 'title', + 'type', + 'videoId', + 'viewCount', + 'watchProgress' + ] + + const historyObject = {} + + Object.keys(historyData).forEach((key) => { + if (!requiredKeys.includes(key)) { + this.showToast({ + message: `Unknown data key: ${key}` + }) + } else { + historyObject[key] = historyData[key] + } + }) + + if (Object.keys(historyObject).length < requiredKeys.length) { + this.showToast({ + message: this.$t('Settings.Data Settings.History object has insufficient data, skipping item') + }) + } else { + this.updateHistory(historyObject) + } + }) + + this.showToast({ + message: this.$t('Settings.Data Settings.All watched history has been successfully imported') + }) + }) + }) + }, + + exportHistory: function () { + const userData = app.getPath('userData') + const historyDb = `${userData}/history.db` + const date = new Date() + let dateMonth = date.getMonth() + 1 + + if (dateMonth < 10) { + dateMonth = '0' + dateMonth + } + + let dateDay = date.getDate() + + if (dateDay < 10) { + dateDay = '0' + dateDay + } + + const dateYear = date.getFullYear() + const exportFileName = 'freetube-history-' + dateYear + '-' + dateMonth + '-' + dateDay + '.db' + + const options = { + defaultPath: exportFileName, + filters: [ + { + name: 'Database File', + extensions: ['db'] + } + ] + } + + dialog.showSaveDialog(options).then((response) => { + if (response.canceled || response.filePath === '') { + // User canceled the save dialog + return + } + + const filePath = response.filePath + + fs.readFile(historyDb, (readErr, data) => { + if (readErr) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${readErr}` + }) + return + } + + fs.writeFile(filePath, data, (writeErr) => { + if (writeErr) { + const message = this.$t('Settings.Data Settings.Unable to write file') + this.showToast({ + message: `${message}: ${writeErr}` + }) + return + } + + this.showToast({ + message: this.$t('Settings.Data Settings.All watched history has been successfully exported') + }) + }) + }) + }) + }, + + getChannelInfoInvidious: function (channelId) { + return new Promise((resolve, reject) => { + const subscriptionsPayload = { + resource: 'channels', + id: channelId, + params: {} + } + + this.invidiousAPICall(subscriptionsPayload).then((response) => { + resolve(response) + }).catch((err) => { + console.log(err) + const errorMessage = this.$t('Invidious API Error (Click to copy)') + this.showToast({ + message: `${errorMessage}: ${err.responseText}`, + time: 10000, + action: () => { + navigator.clipboard.writeText(err) + } + }) + + if (this.backendFallback) { + this.showToast({ + message: this.$t('Falling back to the local API') + }) + resolve(this.getChannelInfoLocal(channelId)) + } else { + resolve([]) + } + }) + }) + }, + + getChannelInfoLocal: function (channelId) { + return new Promise((resolve, reject) => { + ytch.getChannelInfo(channelId, 'latest').then(async (response) => { + resolve(response) + }).catch((err) => { + console.log(err) + const errorMessage = this.$t('Local API Error (Click to copy)') + this.showToast({ + message: `${errorMessage}: ${err}`, + time: 10000, + action: () => { + navigator.clipboard.writeText(err) + } + }) + + if (this.backendFallback) { + this.showToast({ + message: this.$t('Falling back to the Invidious API') + }) + resolve(this.getChannelInfoInvidious(channelId)) + } else { + resolve([]) + } + }) + }) + }, + + ...mapActions([ + 'invidiousAPICall', + 'updateProfile', + 'updateShowProgressBar', + 'updateHistory', + 'showToast' + ]), + + ...mapMutations([ + 'setProgressBarPercentage' + ]) + } +}) diff --git a/src/renderer/components/data-settings/data-settings.sass b/src/renderer/components/data-settings/data-settings.sass new file mode 100644 index 00000000..05cb0dfb --- /dev/null +++ b/src/renderer/components/data-settings/data-settings.sass @@ -0,0 +1 @@ +@use "../../sass-partials/settings" diff --git a/src/renderer/components/data-settings/data-settings.vue b/src/renderer/components/data-settings/data-settings.vue new file mode 100644 index 00000000..faae68ff --- /dev/null +++ b/src/renderer/components/data-settings/data-settings.vue @@ -0,0 +1,44 @@ + + +