Add functionality to import and export subscriptions / history
This commit is contained in:
parent
8308734716
commit
d77c9aed49
|
@ -31,13 +31,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@babel/core": {
|
"@babel/core": {
|
||||||
"version": "7.11.5",
|
"version": "7.11.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz",
|
||||||
"integrity": "sha512-fsEANVOcZHzrsV6dMVWqpSeXClq3lNbYrfFGme6DE25FQWe7pyeYpXyx9guqUnpy466JLzZ8z4uwSr2iv60V5Q==",
|
"integrity": "sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/generator": "^7.11.5",
|
"@babel/generator": "^7.11.6",
|
||||||
"@babel/helper-module-transforms": "^7.11.0",
|
"@babel/helper-module-transforms": "^7.11.0",
|
||||||
"@babel/helpers": "^7.10.4",
|
"@babel/helpers": "^7.10.4",
|
||||||
"@babel/parser": "^7.11.5",
|
"@babel/parser": "^7.11.5",
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
"lodash": "^4.17.19",
|
"lodash": "^4.17.19",
|
||||||
"resolve": "^1.3.2",
|
"resolve": "^1.3.2",
|
||||||
"semver": "^5.4.1",
|
"semver": "^5.4.1",
|
||||||
"source-map": "^0.6.1"
|
"source-map": "^0.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": {
|
"@babel/code-frame": {
|
||||||
|
@ -64,14 +64,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@babel/generator": {
|
"@babel/generator": {
|
||||||
"version": "7.11.5",
|
"version": "7.11.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz",
|
||||||
"integrity": "sha512-9UqHWJ4IwRTy4l0o8gq2ef8ws8UPzvtMkVKjTLAiRmza9p9V6Z+OfuNd9fB1j5Q67F+dVJtPC2sZXI8NM9br4g==",
|
"integrity": "sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/types": "^7.11.5",
|
"@babel/types": "^7.11.5",
|
||||||
"jsesc": "^2.5.1",
|
"jsesc": "^2.5.1",
|
||||||
"source-map": "^0.6.1"
|
"source-map": "^0.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@babel/highlight": {
|
"@babel/highlight": {
|
||||||
|
@ -133,12 +133,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
"dev": true
|
"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": {
|
"@fortawesome/fontawesome-common-types": {
|
||||||
"version": "0.2.30",
|
"version": "0.2.30",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.30.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.30.tgz",
|
||||||
|
@ -1624,9 +1692,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@fortawesome/vue-fontawesome": {
|
"@fortawesome/vue-fontawesome": {
|
||||||
"version": "0.1.10",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-0.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.0.tgz",
|
||||||
"integrity": "sha512-b2+SLF31h32LSepVcXe+BQ63yvbq5qmTCy4KfFogCYm2bn68H5sDWUnX+U7MBqnM2aeEk9M7xSoqGnu+wSdY6w=="
|
"integrity": "sha512-N3VKw7KzRfOm8hShUVldpinlm13HpvLBQgT63QS+aCrIRLwjoEUXY5Rcmttbfb6HkzZaeqjLqd/aZCQ53UjQpg=="
|
||||||
},
|
},
|
||||||
"@istanbuljs/load-nyc-config": {
|
"@istanbuljs/load-nyc-config": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
@ -6956,9 +7024,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"electron": {
|
"electron": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-10.1.1.tgz",
|
||||||
"integrity": "sha512-DyS6WhQ59+ZXQsI1EkpsYkOXFt0Xbp+mbxPTJS9A7O21r3JDzaTC+1Jxz7g6J+Sbi9Y7UFdRs0tn/vqhHJx2gA==",
|
"integrity": "sha512-ZJtZHMr17AvvBosuA6XUmpehwAlGM4/n46Mw9BcyD8tpgdI6IQd0X5OU9meE3X3M8Y6Ja2Kr2udTMgtjvot2hA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@electron/get": "^1.0.1",
|
"@electron/get": "^1.0.1",
|
||||||
|
@ -7853,12 +7921,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"eslint": {
|
"eslint": {
|
||||||
"version": "7.7.0",
|
"version": "7.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-7.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-7.8.1.tgz",
|
||||||
"integrity": "sha512-1KUxLzos0ZVsyL81PnRN335nDtQ8/vZUD6uMtWbF+5zDtjKcsklIi78XoE0MVL93QvWTu+E5y44VyyCsOMBrIg==",
|
"integrity": "sha512-/2rX2pfhyUG0y+A123d0ccXtMm7DV7sH1m3lk9nk2DZ2LReq39FXHueR9xZwshE5MdfSf0xunSaMWRqyIA6M1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/code-frame": "^7.0.0",
|
"@babel/code-frame": "^7.0.0",
|
||||||
|
"@eslint/eslintrc": "^0.1.3",
|
||||||
"ajv": "^6.10.0",
|
"ajv": "^6.10.0",
|
||||||
"chalk": "^4.0.0",
|
"chalk": "^4.0.0",
|
||||||
"cross-spawn": "^7.0.2",
|
"cross-spawn": "^7.0.2",
|
||||||
|
@ -7868,7 +7937,7 @@
|
||||||
"eslint-scope": "^5.1.0",
|
"eslint-scope": "^5.1.0",
|
||||||
"eslint-utils": "^2.1.0",
|
"eslint-utils": "^2.1.0",
|
||||||
"eslint-visitor-keys": "^1.3.0",
|
"eslint-visitor-keys": "^1.3.0",
|
||||||
"espree": "^7.2.0",
|
"espree": "^7.3.0",
|
||||||
"esquery": "^1.2.0",
|
"esquery": "^1.2.0",
|
||||||
"esutils": "^2.0.2",
|
"esutils": "^2.0.2",
|
||||||
"file-entry-cache": "^5.0.1",
|
"file-entry-cache": "^5.0.1",
|
||||||
|
@ -8081,9 +8150,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"supports-color": {
|
"supports-color": {
|
||||||
"version": "7.1.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
"integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
|
@ -8319,12 +8388,12 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"espree": {
|
"espree": {
|
||||||
"version": "7.2.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz",
|
||||||
"integrity": "sha512-H+cQ3+3JYRMEIOl87e7QdHX70ocly5iW4+dttuR8iYSPr/hXKFb+7dBsZ7+u1adC4VrnPlTkv0+OwuPnDop19g==",
|
"integrity": "sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"acorn": "^7.3.1",
|
"acorn": "^7.4.0",
|
||||||
"acorn-jsx": "^5.2.0",
|
"acorn-jsx": "^5.2.0",
|
||||||
"eslint-visitor-keys": "^1.3.0"
|
"eslint-visitor-keys": "^1.3.0"
|
||||||
},
|
},
|
||||||
|
@ -16630,18 +16699,24 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sass-loader": {
|
"sass-loader": {
|
||||||
"version": "10.0.1",
|
"version": "10.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-10.0.2.tgz",
|
||||||
"integrity": "sha512-b2PSldKVTS3JcFPHSrEXh3BeAfR7XknGiGCAO5aHruR3Pf3kqLP3Gb2ypXLglRrAzgZkloNxLZ7GXEGDX0hBUQ==",
|
"integrity": "sha512-wV6NDUVB8/iEYMalV/+139+vl2LaRFlZGEd5/xmdcdzQcgmis+npyco6NsDTVOlNA3y2NV9Gcz+vHyFMIT+ffg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"klona": "^2.0.3",
|
"klona": "^2.0.3",
|
||||||
"loader-utils": "^2.0.0",
|
"loader-utils": "^2.0.0",
|
||||||
"neo-async": "^2.6.2",
|
"neo-async": "^2.6.2",
|
||||||
"schema-utils": "^2.7.0",
|
"schema-utils": "^2.7.1",
|
||||||
"semver": "^7.3.2"
|
"semver": "^7.3.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": {
|
"ajv": {
|
||||||
"version": "6.12.4",
|
"version": "6.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.4.tgz",
|
||||||
|
@ -16696,14 +16771,14 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"schema-utils": {
|
"schema-utils": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz",
|
||||||
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
|
"integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/json-schema": "^7.0.4",
|
"@types/json-schema": "^7.0.5",
|
||||||
"ajv": "^6.12.2",
|
"ajv": "^6.12.4",
|
||||||
"ajv-keywords": "^3.4.1"
|
"ajv-keywords": "^3.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"semver": {
|
"semver": {
|
||||||
|
|
10
package.json
10
package.json
|
@ -10,7 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
"@fortawesome/fontawesome-svg-core": "^1.2.30",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.14.0",
|
"@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",
|
"@silvermine/videojs-quality-selector": "^1.2.4",
|
||||||
"autolinker": "^3.14.1",
|
"autolinker": "^3.14.1",
|
||||||
"bulma-pro": "^0.2.0",
|
"bulma-pro": "^0.2.0",
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
},
|
},
|
||||||
"description": "A private YouTube client",
|
"description": "A private YouTube client",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.11.5",
|
"@babel/core": "^7.11.6",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.10.4",
|
"@babel/plugin-proposal-class-properties": "^7.10.4",
|
||||||
"@babel/plugin-proposal-object-rest-spread": "^7.11.0",
|
"@babel/plugin-proposal-object-rest-spread": "^7.11.0",
|
||||||
"@babel/preset-env": "^7.11.5",
|
"@babel/preset-env": "^7.11.5",
|
||||||
|
@ -66,12 +66,12 @@
|
||||||
"copy-webpack-plugin": "^6.1.0",
|
"copy-webpack-plugin": "^6.1.0",
|
||||||
"css-loader": "^4.2.2",
|
"css-loader": "^4.2.2",
|
||||||
"devtron": "^1.4.0",
|
"devtron": "^1.4.0",
|
||||||
"electron": "^10.1.0",
|
"electron": "^10.1.1",
|
||||||
"electron-builder": "^22.8.0",
|
"electron-builder": "^22.8.0",
|
||||||
"electron-builder-squirrel-windows": "^22.8.1",
|
"electron-builder-squirrel-windows": "^22.8.1",
|
||||||
"electron-debug": "^3.1.0",
|
"electron-debug": "^3.1.0",
|
||||||
"electron-rebuild": "^2.0.1",
|
"electron-rebuild": "^2.0.1",
|
||||||
"eslint": "^7.7.0",
|
"eslint": "^7.8.1",
|
||||||
"eslint-config-prettier": "^6.11.0",
|
"eslint-config-prettier": "^6.11.0",
|
||||||
"eslint-config-standard": "^14.1.1",
|
"eslint-config-standard": "^14.1.1",
|
||||||
"eslint-plugin-import": "^2.22.0",
|
"eslint-plugin-import": "^2.22.0",
|
||||||
|
@ -90,7 +90,7 @@
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^2.1.1",
|
"prettier": "^2.1.1",
|
||||||
"sass": "^1.26.10",
|
"sass": "^1.26.10",
|
||||||
"sass-loader": "^10.0.1",
|
"sass-loader": "^10.0.2",
|
||||||
"style-loader": "^1.2.1",
|
"style-loader": "^1.2.1",
|
||||||
"tree-kill": "1.2.2",
|
"tree-kill": "1.2.2",
|
||||||
"typescript": "^4.0.2",
|
"typescript": "^4.0.2",
|
||||||
|
|
|
@ -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 = '<opml version="1.1"><body><outline text="YouTube Subscriptions" title="YouTube Subscriptions">'
|
||||||
|
const endingOpmlString = '</outline></body></opml>'
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
this.profileList[0].subscriptions.forEach((channel) => {
|
||||||
|
const channelOpmlString = `<outline text="${channel.name}" title="${channel.name}" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}"/>`
|
||||||
|
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'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1 @@
|
||||||
|
@use "../../sass-partials/settings"
|
|
@ -0,0 +1,44 @@
|
||||||
|
<template>
|
||||||
|
<ft-card
|
||||||
|
class="relative card"
|
||||||
|
>
|
||||||
|
<h3>
|
||||||
|
{{ $t("Settings.Data Settings.Data Settings") }}
|
||||||
|
</h3>
|
||||||
|
<ft-flex-box>
|
||||||
|
<ft-button
|
||||||
|
:label="$t('Settings.Data Settings.Import Subscriptions')"
|
||||||
|
@click="showImportSubscriptionsPrompt = true"
|
||||||
|
/>
|
||||||
|
<ft-button
|
||||||
|
:label="$t('Settings.Data Settings.Export Subscriptions')"
|
||||||
|
@click="showExportSubscriptionsPrompt = true"
|
||||||
|
/>
|
||||||
|
<ft-button
|
||||||
|
:label="$t('Settings.Data Settings.Import History')"
|
||||||
|
@click="importHistory"
|
||||||
|
/>
|
||||||
|
<ft-button
|
||||||
|
:label="$t('Settings.Data Settings.Export Subscriptions')"
|
||||||
|
@click="exportHistory"
|
||||||
|
/>
|
||||||
|
</ft-flex-box>
|
||||||
|
<ft-prompt
|
||||||
|
v-if="showImportSubscriptionsPrompt"
|
||||||
|
:label="$t('Settings.Data Settings.Select Import Type')"
|
||||||
|
:option-names="importSubscriptionsPromptNames"
|
||||||
|
:option-values="subscriptionsPromptValues"
|
||||||
|
@click="importSubscriptions"
|
||||||
|
/>
|
||||||
|
<ft-prompt
|
||||||
|
v-if="showExportSubscriptionsPrompt"
|
||||||
|
:label="$t('Settings.Data Settings.Select Export Type')"
|
||||||
|
:option-names="exportSubscriptionsPromptNames"
|
||||||
|
:option-values="subscriptionsPromptValues"
|
||||||
|
@click="exportSubscriptions"
|
||||||
|
/>
|
||||||
|
</ft-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./data-settings.js" />
|
||||||
|
<style scoped lang="sass" src="./data-settings.sass" />
|
|
@ -20,7 +20,7 @@
|
||||||
top: 60px;
|
top: 60px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
height: 300px;
|
height: 400px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
background-color: var(--card-bg-color);
|
background-color: var(--card-bg-color);
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.1);
|
box-shadow: 0 1px 2px rgba(0,0,0,.1);
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
.profileWrapper {
|
.profileWrapper {
|
||||||
margin-top: 60px;
|
margin-top: 60px;
|
||||||
height: 240px;
|
height: 340px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ export default Vue.extend({
|
||||||
return {
|
return {
|
||||||
showSearchCachePrompt: false,
|
showSearchCachePrompt: false,
|
||||||
showRemoveHistoryPrompt: false,
|
showRemoveHistoryPrompt: false,
|
||||||
|
showRemoveSubscriptionsPrompt: false,
|
||||||
promptValues: [
|
promptValues: [
|
||||||
'yes',
|
'yes',
|
||||||
'no'
|
'no'
|
||||||
|
@ -32,6 +33,12 @@ export default Vue.extend({
|
||||||
saveWatchedProgress: function () {
|
saveWatchedProgress: function () {
|
||||||
return this.$store.getters.getSaveWatchedProgress
|
return this.$store.getters.getSaveWatchedProgress
|
||||||
},
|
},
|
||||||
|
profileList: function () {
|
||||||
|
return this.$store.getters.getProfileList
|
||||||
|
},
|
||||||
|
removeSubscriptionsPromptMessage: function () {
|
||||||
|
return this.$t('Settings.Privacy Settings["Are you sure you want to remove all subscriptions and profiles? This cannot be undone."]')
|
||||||
|
},
|
||||||
promptNames: function () {
|
promptNames: function () {
|
||||||
return [
|
return [
|
||||||
this.$t('Yes'),
|
this.$t('Yes'),
|
||||||
|
@ -62,11 +69,37 @@ export default Vue.extend({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleRemoveSubscriptions: function (option) {
|
||||||
|
this.showRemoveSubscriptionsPrompt = false
|
||||||
|
|
||||||
|
this.updateActiveProfile(0)
|
||||||
|
|
||||||
|
if (option === 'yes') {
|
||||||
|
this.profileList.forEach((profile) => {
|
||||||
|
if (profile._id === 'allChannels') {
|
||||||
|
const newProfile = {
|
||||||
|
_id: 'allChannels',
|
||||||
|
name: profile.name,
|
||||||
|
bgColor: profile.bgColor,
|
||||||
|
textColor: profile.textColor,
|
||||||
|
subscriptions: []
|
||||||
|
}
|
||||||
|
this.updateProfile(newProfile)
|
||||||
|
} else {
|
||||||
|
this.removeProfile(profile._id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
...mapActions([
|
...mapActions([
|
||||||
'updateRememberHistory',
|
'updateRememberHistory',
|
||||||
'removeAllHistory',
|
'removeAllHistory',
|
||||||
'updateSaveWatchedProgress',
|
'updateSaveWatchedProgress',
|
||||||
'clearSessionSearchHistory',
|
'clearSessionSearchHistory',
|
||||||
|
'updateProfile',
|
||||||
|
'removeProfile',
|
||||||
|
'updateActiveProfile',
|
||||||
'showToast'
|
'showToast'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,12 @@
|
||||||
background-color="var(--primary-color)"
|
background-color="var(--primary-color)"
|
||||||
@click="showRemoveHistoryPrompt = true"
|
@click="showRemoveHistoryPrompt = true"
|
||||||
/>
|
/>
|
||||||
|
<ft-button
|
||||||
|
:label="$t('Settings.Privacy Settings.Remove All Subscriptions / Profiles')"
|
||||||
|
text-color="var(--text-with-main-color)"
|
||||||
|
background-color="var(--primary-color)"
|
||||||
|
@click="showRemoveSubscriptionsPrompt = true"
|
||||||
|
/>
|
||||||
</ft-flex-box>
|
</ft-flex-box>
|
||||||
<ft-prompt
|
<ft-prompt
|
||||||
v-if="showSearchCachePrompt"
|
v-if="showSearchCachePrompt"
|
||||||
|
@ -52,6 +58,13 @@
|
||||||
:option-values="promptValues"
|
:option-values="promptValues"
|
||||||
@click="handleRemoveHistory"
|
@click="handleRemoveHistory"
|
||||||
/>
|
/>
|
||||||
|
<ft-prompt
|
||||||
|
v-if="showRemoveSubscriptionsPrompt"
|
||||||
|
:label="removeSubscriptionsPromptMessage"
|
||||||
|
:option-names="promptNames"
|
||||||
|
:option-values="promptValues"
|
||||||
|
@click="handleRemoveSubscriptions"
|
||||||
|
/>
|
||||||
</ft-card>
|
</ft-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,9 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
|
||||||
import GeneralSettings from '../../components/general-settings/general-settings.vue'
|
import GeneralSettings from '../../components/general-settings/general-settings.vue'
|
||||||
import ThemeSettings from '../../components/theme-settings/theme-settings.vue'
|
import ThemeSettings from '../../components/theme-settings/theme-settings.vue'
|
||||||
import PlayerSettings from '../../components/player-settings/player-settings.vue'
|
import PlayerSettings from '../../components/player-settings/player-settings.vue'
|
||||||
import PrivacySettings from '../../components/privacy-settings/privacy-settings.vue'
|
|
||||||
import SubscriptionSettings from '../../components/subscription-settings/subscription-settings.vue'
|
import SubscriptionSettings from '../../components/subscription-settings/subscription-settings.vue'
|
||||||
|
import PrivacySettings from '../../components/privacy-settings/privacy-settings.vue'
|
||||||
|
import DataSettings from '../../components/data-settings/data-settings.vue'
|
||||||
|
|
||||||
export default Vue.extend({
|
export default Vue.extend({
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
@ -15,14 +16,8 @@ export default Vue.extend({
|
||||||
'general-settings': GeneralSettings,
|
'general-settings': GeneralSettings,
|
||||||
'theme-settings': ThemeSettings,
|
'theme-settings': ThemeSettings,
|
||||||
'player-settings': PlayerSettings,
|
'player-settings': PlayerSettings,
|
||||||
|
'subscription-settings': SubscriptionSettings,
|
||||||
'privacy-settings': PrivacySettings,
|
'privacy-settings': PrivacySettings,
|
||||||
'subscription-settings': SubscriptionSettings
|
'data-settings': DataSettings
|
||||||
},
|
|
||||||
mounted: function () {
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
handleToggleSwitch: function (event) {
|
|
||||||
console.log(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<player-settings />
|
<player-settings />
|
||||||
<subscription-settings />
|
<subscription-settings />
|
||||||
<privacy-settings />
|
<privacy-settings />
|
||||||
|
<data-settings />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
import { mapActions } from 'vuex'
|
||||||
import FtCard from '../../components/ft-card/ft-card.vue'
|
import FtCard from '../../components/ft-card/ft-card.vue'
|
||||||
import FtLoader from '../../components/ft-loader/ft-loader.vue'
|
import FtLoader from '../../components/ft-loader/ft-loader.vue'
|
||||||
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
|
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
|
||||||
|
@ -121,7 +122,7 @@ export default Vue.extend({
|
||||||
console.log(err)
|
console.log(err)
|
||||||
const errorMessage = this.$t('Invidious API Error (Click to copy)')
|
const errorMessage = this.$t('Invidious API Error (Click to copy)')
|
||||||
this.showToast({
|
this.showToast({
|
||||||
message: `${errorMessage}: ${err}`,
|
message: `${errorMessage}: ${err.responseText}`,
|
||||||
time: 10000,
|
time: 10000,
|
||||||
action: () => {
|
action: () => {
|
||||||
navigator.clipboard.writeText(err)
|
navigator.clipboard.writeText(err)
|
||||||
|
@ -137,6 +138,10 @@ export default Vue.extend({
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
|
||||||
|
...mapActions([
|
||||||
|
'showToast'
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -178,18 +178,40 @@ Settings:
|
||||||
Are you sure you want to remove your entire watch history?: Are you sure you want
|
Are you sure you want to remove your entire watch history?: Are you sure you want
|
||||||
to remove your entire watch history?
|
to remove your entire watch history?
|
||||||
Watch history has been cleared: Watch history has been cleared
|
Watch history has been cleared: Watch history has been cleared
|
||||||
|
Remove All Subscriptions / Profiles: Remove All Subscriptions / Profiles
|
||||||
|
Are you sure you want to remove all subscriptions and profiles? This cannot be undone.: Are you sure you want to remove all subscriptions and profiles? This cannot be undone.
|
||||||
Subscription Settings:
|
Subscription Settings:
|
||||||
Subscription Settings: Subscription Settings
|
Subscription Settings: Subscription Settings
|
||||||
Hide Videos on Watch: Hide Videos on Watch
|
Hide Videos on Watch: Hide Videos on Watch
|
||||||
Fetch Feeds from RSS: Fetch Feeds from RSS
|
Fetch Feeds from RSS: Fetch Feeds from RSS
|
||||||
Subscriptions Export Format:
|
|
||||||
Subscriptions Export Format: Subscriptions Export Format
|
|
||||||
#& Freetube
|
|
||||||
Newpipe: Newpipe
|
|
||||||
OPML: OPML
|
|
||||||
Manage Subscriptions: Manage Subscriptions
|
Manage Subscriptions: Manage Subscriptions
|
||||||
|
Data Settings:
|
||||||
|
Data Settings: Data Settings
|
||||||
|
Select Import Type: Select Import Type
|
||||||
|
Select Export Type: Select Export Type
|
||||||
Import Subscriptions: Import Subscriptions
|
Import Subscriptions: Import Subscriptions
|
||||||
|
Import FreeTube: Import FreeTube
|
||||||
|
Import YouTube: Import YouTube
|
||||||
|
Import NewPipe: Import NewPipe
|
||||||
Export Subscriptions: Export Subscriptions
|
Export Subscriptions: Export Subscriptions
|
||||||
|
Export FreeTube: Export FreeTube
|
||||||
|
Export YouTube: Export YouTube
|
||||||
|
Export NewPipe: Export NewPipe
|
||||||
|
Import History: Import History
|
||||||
|
Export History: Export History
|
||||||
|
Profile object has insufficient data, skipping item: Profile object has insufficient data, skipping item
|
||||||
|
All subscriptions and profiles have been successfully imported: All subscriptions and profiles have been successfully imported
|
||||||
|
All subscriptions have been successfully imported: All subscriptions have been successfully imported
|
||||||
|
Invalid subscriptions file: Invalid subscriptions file
|
||||||
|
This might take a while, please wait: This might take a while, please wait
|
||||||
|
Invalid history file: Invalid history file
|
||||||
|
Subscriptions have been successfully exported: Subscriptions have been successfully exported
|
||||||
|
History object has insufficient data, skipping item: History object has insufficient data, skipping item
|
||||||
|
All watched history has been successfully imported: All watched history has been successfully imported
|
||||||
|
All watched history has been successfully exported: All watched history has been successfully exported
|
||||||
|
Unable to read file: Unable to read file
|
||||||
|
Unable to write file: Unable to write file
|
||||||
|
Unknown data key: Unknown data key
|
||||||
How do I import my subscriptions?: How do I import my subscriptions?
|
How do I import my subscriptions?: How do I import my subscriptions?
|
||||||
Advanced Settings:
|
Advanced Settings:
|
||||||
Advanced Settings: Advanced Settings
|
Advanced Settings: Advanced Settings
|
||||||
|
|
Loading…
Reference in New Issue