diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index 7e823a46..63bd50f4 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -80,6 +80,9 @@ export default Vue.extend({ `${exportYouTube} (.opml)`, `${exportNewPipe} (.json)` ] + }, + usingElectron: function () { + return process.env.IS_ELECTRON } }, methods: { @@ -115,106 +118,105 @@ export default Vue.extend({ } }, - handleFreetubeImportFile: function (filePath) { - 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 - } + handleFreetubeImportFile: async function (response) { + let textDecode + try { + textDecode = await this.readFileFromDialog({ response }) + } catch (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + textDecode = textDecode.split('\n') + textDecode.pop() + textDecode = textDecode.map(data => JSON.parse(data)) - let textDecode = new TextDecoder('utf-8').decode(data) - textDecode = textDecode.split('\n') - textDecode.pop() - textDecode = textDecode.map(data => JSON.parse(data)) + const firstEntry = textDecode[0] + if (firstEntry.channelId && firstEntry.channelName && firstEntry.channelThumbnail && firstEntry._id && firstEntry.profile) { + // Old FreeTube subscriptions format detected, so convert it to the new one: + textDecode = await this.convertOldFreeTubeFormatToNew(textDecode) + } - const firstEntry = textDecode[0] - if (firstEntry.channelId && firstEntry.channelName && firstEntry.channelThumbnail && firstEntry._id && firstEntry.profile) { - // Old FreeTube subscriptions format detected, so convert it to the new one: - textDecode = await this.convertOldFreeTubeFormatToNew(textDecode) - } + const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) - const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) + textDecode.forEach((profileData) => { + // 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. - textDecode.forEach((profileData) => { - // 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 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') + 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 + message: `${message}: ${key}` }) } else { - if (profileObject.name === 'All Channels' || profileObject._id === MAIN_PROFILE_ID) { - primaryProfile.subscriptions = primaryProfile.subscriptions.concat(profileObject.subscriptions) - primaryProfile.subscriptions = primaryProfile.subscriptions.filter((sub, index) => { - const profileIndex = primaryProfile.subscriptions.findIndex((x) => { - return x.name === sub.name - }) - - return profileIndex === index - }) - this.updateProfile(primaryProfile) - } else { - const existingProfileIndex = this.profileList.findIndex((profile) => { - return profile.name.includes(profileObject.name) - }) - - if (existingProfileIndex !== -1) { - const existingProfile = JSON.parse(JSON.stringify(this.profileList[existingProfileIndex])) - existingProfile.subscriptions = existingProfile.subscriptions.concat(profileObject.subscriptions) - existingProfile.subscriptions = existingProfile.subscriptions.filter((sub, index) => { - const profileIndex = existingProfile.subscriptions.findIndex((x) => { - return x.name === sub.name - }) - - return profileIndex === index - }) - this.updateProfile(existingProfile) - } else { - this.updateProfile(profileObject) - } - - primaryProfile.subscriptions = primaryProfile.subscriptions.concat(profileObject.subscriptions) - primaryProfile.subscriptions = primaryProfile.subscriptions.filter((sub, index) => { - const profileIndex = primaryProfile.subscriptions.findIndex((x) => { - return x.name === sub.name - }) - - return profileIndex === index - }) - this.updateProfile(primaryProfile) - } + profileObject[key] = profileData[key] } }) - this.showToast({ - message: this.$t('Settings.Data Settings.All subscriptions and profiles have been successfully imported') - }) + 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 { + if (profileObject.name === 'All Channels' || profileObject._id === MAIN_PROFILE_ID) { + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(profileObject.subscriptions) + primaryProfile.subscriptions = primaryProfile.subscriptions.filter((sub, index) => { + const profileIndex = primaryProfile.subscriptions.findIndex((x) => { + return x.name === sub.name + }) + + return profileIndex === index + }) + this.updateProfile(primaryProfile) + } else { + const existingProfileIndex = this.profileList.findIndex((profile) => { + return profile.name.includes(profileObject.name) + }) + + if (existingProfileIndex !== -1) { + const existingProfile = JSON.parse(JSON.stringify(this.profileList[existingProfileIndex])) + existingProfile.subscriptions = existingProfile.subscriptions.concat(profileObject.subscriptions) + existingProfile.subscriptions = existingProfile.subscriptions.filter((sub, index) => { + const profileIndex = existingProfile.subscriptions.findIndex((x) => { + return x.name === sub.name + }) + + return profileIndex === index + }) + this.updateProfile(existingProfile) + } else { + this.updateProfile(profileObject) + } + + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(profileObject.subscriptions) + primaryProfile.subscriptions = primaryProfile.subscriptions.filter((sub, index) => { + const profileIndex = primaryProfile.subscriptions.findIndex((x) => { + return x.name === sub.name + }) + + return profileIndex === index + }) + this.updateProfile(primaryProfile) + } + } + }) + + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions and profiles have been successfully imported') }) }, @@ -230,161 +232,159 @@ export default Vue.extend({ } const response = await this.showOpenDialog(options) - if (response.canceled || response.filePaths.length === 0) { + if (response.canceled || response.filePaths?.length === 0) { return } - const filePath = response.filePaths[0] - this.handleFreetubeImportFile(filePath) + this.handleFreetubeImportFile(response) }, - handleYoutubeCsvImportFile: function(filePath) { // first row = header, last row = empty - 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 textDecode = new TextDecoder('utf-8').decode(data) - const youtubeSubscriptions = textDecode.split('\n').filter(sub => { - return sub !== '' - }) - const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) - const subscriptions = [] - + handleYoutubeCsvImportFile: async function(response) { // first row = header, last row = empty + let textDecode + try { + textDecode = await this.readFileFromDialog({ response }) + } catch (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') this.showToast({ - message: this.$t('Settings.Data Settings.This might take a while, please wait') + message: `${message}: ${err}` }) - - this.updateShowProgressBar(true) - this.setProgressBarPercentage(0) - let count = 0 - for (let i = 1; i < (youtubeSubscriptions.length - 1); i++) { - const channelId = youtubeSubscriptions[i].split(',')[0] - const subExists = primaryProfile.subscriptions.findIndex((sub) => { - return sub.id === channelId - }) - if (subExists === -1) { - let channelInfo - if (this.backendPreference === 'invidious') { // only needed for thumbnail - channelInfo = await this.getChannelInfoInvidious(channelId) - } else { - channelInfo = await this.getChannelInfoLocal(channelId) - } - - if (typeof channelInfo.author !== 'undefined') { - const subscription = { - id: channelId, - name: channelInfo.author, - thumbnail: channelInfo.authorThumbnails[1].url - } - subscriptions.push(subscription) - } - } - - count++ - - const progressPercentage = (count / (youtubeSubscriptions.length - 1)) * 100 - this.setProgressBarPercentage(progressPercentage) - if (count + 1 === (youtubeSubscriptions.length - 1)) { - primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) - this.updateProfile(primaryProfile) - - if (subscriptions.length < count + 2) { - this.showToast({ - message: this.$t('Settings.Data Settings.One or more subscriptions were unable to be imported') - }) - } else { - this.showToast({ - message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') - }) - } - - this.updateShowProgressBar(false) - } - } + return + } + const youtubeSubscriptions = textDecode.split('\n').filter(sub => { + return sub !== '' }) - }, + const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) + const subscriptions = [] - handleYoutubeImportFile: function (filePath) { - 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 - } + this.showToast({ + message: this.$t('Settings.Data Settings.This might take a while, please wait') + }) - let textDecode = new TextDecoder('utf-8').decode(data) - textDecode = JSON.parse(textDecode) - - 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) + let count = 0 + for (let i = 1; i < (youtubeSubscriptions.length - 1); i++) { + const channelId = youtubeSubscriptions[i].split(',')[0] + const subExists = primaryProfile.subscriptions.findIndex((sub) => { + return sub.id === channelId }) - - this.updateShowProgressBar(true) - this.setProgressBarPercentage(0) - - let count = 0 - - textDecode.forEach((channel) => { - const snippet = channel.snippet - - if (typeof snippet === 'undefined') { - const message = this.$t('Settings.Data Settings.Invalid subscriptions file') - this.showToast({ - message: message - }) - - throw new Error('Unable to find channel data') + if (subExists === -1) { + let channelInfo + if (this.backendPreference === 'invidious') { // only needed for thumbnail + channelInfo = await this.getChannelInfoInvidious(channelId) + } else { + channelInfo = await this.getChannelInfoLocal(channelId) } - const subscription = { - id: snippet.resourceId.channelId, - name: snippet.title, - thumbnail: snippet.thumbnails.default.url - } - - const subExists = primaryProfile.subscriptions.findIndex((sub) => { - return sub.id === subscription.id || sub.name === subscription.name - }) - - const subDuplicateExists = subscriptions.findIndex((sub) => { - return sub.id === subscription.id || sub.name === subscription.name - }) - - if (subExists === -1 && subDuplicateExists === -1) { + if (typeof channelInfo.author !== 'undefined') { + const subscription = { + id: channelId, + name: channelInfo.author, + thumbnail: channelInfo.authorThumbnails[1].url + } subscriptions.push(subscription) } + } - count++ + count++ - const progressPercentage = (count / textDecode.length) * 100 - this.setProgressBarPercentage(progressPercentage) + const progressPercentage = (count / (youtubeSubscriptions.length - 1)) * 100 + this.setProgressBarPercentage(progressPercentage) + if (count + 1 === (youtubeSubscriptions.length - 1)) { + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) + this.updateProfile(primaryProfile) - if (count === textDecode.length) { - primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) - this.updateProfile(primaryProfile) - - if (subscriptions.length < count) { - this.showToast({ - message: this.$t('Settings.Data Settings.One or more subscriptions were unable to be imported') - }) - } else { - this.showToast({ - message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') - }) - } - - this.updateShowProgressBar(false) + if (subscriptions.length < count + 2) { + this.showToast({ + message: this.$t('Settings.Data Settings.One or more subscriptions were unable to be imported') + }) + } else { + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') + }) } + + this.updateShowProgressBar(false) + } + } + }, + + handleYoutubeImportFile: async function (response) { + let textDecode + try { + textDecode = await this.readFileFromDialog({ response }) + } catch (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` }) + return + } + textDecode = JSON.parse(textDecode) + + 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) + + let count = 0 + + textDecode.forEach((channel) => { + const snippet = channel.snippet + + if (typeof snippet === 'undefined') { + const message = this.$t('Settings.Data Settings.Invalid subscriptions file') + this.showToast({ + message: message + }) + + throw new Error('Unable to find channel data') + } + + const subscription = { + id: snippet.resourceId.channelId, + name: snippet.title, + thumbnail: snippet.thumbnails.default.url + } + + const subExists = primaryProfile.subscriptions.findIndex((sub) => { + return sub.id === subscription.id || sub.name === subscription.name + }) + + const subDuplicateExists = subscriptions.findIndex((sub) => { + return sub.id === subscription.id || sub.name === subscription.name + }) + + if (subExists === -1 && subDuplicateExists === -1) { + subscriptions.push(subscription) + } + + count++ + + const progressPercentage = (count / textDecode.length) * 100 + this.setProgressBarPercentage(progressPercentage) + + if (count === textDecode.length) { + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) + this.updateProfile(primaryProfile) + + if (subscriptions.length < count) { + this.showToast({ + message: this.$t('Settings.Data Settings.One or more subscriptions were unable to be imported') + }) + } else { + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') + }) + } + + this.updateShowProgressBar(false) + } }) }, @@ -399,12 +399,11 @@ export default Vue.extend({ ] } const response = await this.showOpenDialog(options) - if (response.canceled || response.filePaths.length === 0) { + if (response.canceled || response.filePaths?.length === 0) { return } - const filePath = response.filePaths[0] - this.handleYoutubeCsvImportFile(filePath) + this.handleYoutubeCsvImportFile(response) }, importYouTubeSubscriptions: async function () { @@ -419,12 +418,11 @@ export default Vue.extend({ } const response = await this.showOpenDialog(options) - if (response.canceled || response.filePaths.length === 0) { + if (response.canceled || response.filePaths?.length === 0) { return } - const filePath = response.filePaths[0] - this.handleYoutubeImportFile(filePath) + this.handleYoutubeImportFile(response) }, importOpmlYouTubeSubscriptions: async function () { @@ -439,145 +437,48 @@ export default Vue.extend({ } const response = await this.showOpenDialog(options) - if (response.canceled || response.filePaths.length === 0) { + if (response.canceled || response.filePaths?.length === 0) { return } - const filePath = response.filePaths[0] + let data + try { + data = await this.readFileFromDialog({ response }) + } catch (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } - 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 json + try { + json = await opmlToJSON(data) + } catch (err) { + console.error(err) + console.error('error reading') + const message = this.$t('Settings.Data Settings.Invalid subscriptions file') + this.showToast({ + message: `${message}: ${err}` + }) + } - opmlToJSON(data).then((json) => { - let feedData = json.children[0].children - - if (typeof feedData === 'undefined') { - if (json.title.includes('gPodder')) { - feedData = json.children - } else { - 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) - - let count = 0 - - feedData.forEach(async (channel, index) => { - const channelId = channel.xmlurl.replace('https://www.youtube.com/feeds/videos.xml?channel_id=', '') - const subExists = primaryProfile.subscriptions.findIndex((sub) => { - return sub.id === channelId + if (json !== undefined) { + let feedData = json.children[0].children + if (typeof feedData === 'undefined') { + if (json.title.includes('gPodder')) { + feedData = json.children + } else { + const message = this.$t('Settings.Data Settings.Invalid subscriptions file') + this.showToast({ + message: message }) - if (subExists === -1) { - let channelInfo - if (this.backendPreference === 'invidious') { - channelInfo = await this.getChannelInfoInvidious(channelId) - } else { - channelInfo = await this.getChannelInfoLocal(channelId) - } - if (typeof channelInfo.author !== 'undefined') { - const subscription = { - id: channelId, - name: channelInfo.author, - thumbnail: channelInfo.authorThumbnails[1].url - } - subscriptions.push(subscription) - } - } - - count++ - - const progressPercentage = (count / feedData.length) * 100 - this.setProgressBarPercentage(progressPercentage) - - if (count === feedData.length) { - primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) - this.updateProfile(primaryProfile) - - if (subscriptions.length < count) { - this.showToast({ - message: this.$t('Settings.Data Settings.One or more subscriptions were unable to be imported') - }) - } else { - this.showToast({ - message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') - }) - } - - this.updateShowProgressBar(false) - } - }) - }).catch((err) => { - console.error(err) - const message = this.$t('Settings.Data Settings.Invalid subscriptions file') - this.showToast({ - message: `${message}: ${err}` - }) - }) - }) - }, - - importNewPipeSubscriptions: async function () { - const options = { - properties: ['openFile'], - filters: [ - { - name: 'Database File', - extensions: ['json'] + return } - ] - } - - const response = await this.showOpenDialog(options) - 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.filter((channel, index) => { - return channel.service_id === 0 - }) - const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) const subscriptions = [] @@ -590,12 +491,11 @@ export default Vue.extend({ let count = 0 - newPipeSubscriptions.forEach(async (channel, index) => { - const channelId = channel.url.replace(/https:\/\/(www\.)?youtube\.com\/channel\//, '') + feedData.forEach(async (channel, index) => { + const channelId = channel.xmlurl.replace('https://www.youtube.com/feeds/videos.xml?channel_id=', '') const subExists = primaryProfile.subscriptions.findIndex((sub) => { return sub.id === channelId }) - if (subExists === -1) { let channelInfo if (this.backendPreference === 'invidious') { @@ -616,10 +516,10 @@ export default Vue.extend({ count++ - const progressPercentage = (count / newPipeSubscriptions.length) * 100 + const progressPercentage = (count / feedData.length) * 100 this.setProgressBarPercentage(progressPercentage) - if (count === newPipeSubscriptions.length) { + if (count === feedData.length) { primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) this.updateProfile(primaryProfile) @@ -636,6 +536,107 @@ export default Vue.extend({ this.updateShowProgressBar(false) } }) + } + }, + + importNewPipeSubscriptions: async function () { + const options = { + properties: ['openFile'], + filters: [ + { + name: 'Database File', + extensions: ['json'] + } + ] + } + + const response = await this.showOpenDialog(options) + if (response.canceled || response.filePaths?.length === 0) { + return + } + + let data + try { + data = await this.readFileFromDialog({ response }) + } catch (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.filter((channel, index) => { + return channel.service_id === 0 + }) + + 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) + + let count = 0 + + newPipeSubscriptions.forEach(async (channel, index) => { + const channelId = channel.url.replace(/https:\/\/(www\.)?youtube\.com\/channel\//, '') + const subExists = primaryProfile.subscriptions.findIndex((sub) => { + return sub.id === channelId + }) + + if (subExists === -1) { + let channelInfo + if (this.backendPreference === 'invidious') { + channelInfo = await this.getChannelInfoInvidious(channelId) + } else { + channelInfo = await this.getChannelInfoLocal(channelId) + } + + if (typeof channelInfo.author !== 'undefined') { + const subscription = { + id: channelId, + name: channelInfo.author, + thumbnail: channelInfo.authorThumbnails[1].url + } + subscriptions.push(subscription) + } + } + + count++ + + const progressPercentage = (count / newPipeSubscriptions.length) * 100 + this.setProgressBarPercentage(progressPercentage) + + if (count === newPipeSubscriptions.length) { + primaryProfile.subscriptions = primaryProfile.subscriptions.concat(subscriptions) + this.updateProfile(primaryProfile) + + if (subscriptions.length < count) { + this.showToast({ + message: this.$t('Settings.Data Settings.One or more subscriptions were unable to be imported') + }) + } else { + this.showToast({ + message: this.$t('Settings.Data Settings.All subscriptions have been successfully imported') + }) + } + + this.updateShowProgressBar(false) + } }) }, @@ -942,7 +943,7 @@ export default Vue.extend({ checkForLegacySubscriptions: async function () { let dbLocation = await this.getUserDataPath() dbLocation = dbLocation + '/subscriptions.db' - this.handleFreetubeImportFile(dbLocation) + this.handleFreetubeImportFile({ canceled: false, filePaths: [dbLocation] }) fs.unlink(dbLocation, (err) => { if (err) { console.error(err) @@ -962,71 +963,67 @@ export default Vue.extend({ } const response = await this.showOpenDialog(options) - if (response.canceled || response.filePaths.length === 0) { + if (response.canceled || response.filePaths?.length === 0) { return } + let textDecode + try { + textDecode = await this.readFileFromDialog({ response }) + } catch (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + textDecode = textDecode.split('\n') + textDecode.pop() - const filePath = response.filePaths[0] + 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' + ] - 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 historyObject = {} - 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 - 2)) { + Object.keys(historyData).forEach((key) => { + if (!requiredKeys.includes(key)) { this.showToast({ - message: this.$t('Settings.Data Settings.History object has insufficient data, skipping item') + message: `Unknown data key: ${key}` }) } else { - this.updateHistory(historyObject) + historyObject[key] = historyData[key] } }) - this.showToast({ - message: this.$t('Settings.Data Settings.All watched history has been successfully imported') - }) + if (Object.keys(historyObject).length < (requiredKeys.length - 2)) { + 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') }) }, @@ -1092,116 +1089,113 @@ export default Vue.extend({ } const response = await this.showOpenDialog(options) - if (response.canceled || response.filePaths.length === 0) { + if (response.canceled || response.filePaths?.length === 0) { return } + let data + try { + data = await this.readFileFromDialog({ response }) + } catch (err) { + const message = this.$t('Settings.Data Settings.Unable to read file') + this.showToast({ + message: `${message}: ${err}` + }) + return + } + const playlists = JSON.parse(data) - const filePath = response.filePaths[0] + playlists.forEach(async (playlistData) => { + // 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 = [ + 'playlistName', + 'videos' + ] - 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 optionalKeys = [ + '_id', + 'protected', + 'removeOnWatched' + ] - const playlists = JSON.parse(data) + const requiredVideoKeys = [ + 'videoId', + 'title', + 'author', + 'authorId', + 'published', + 'lengthSeconds', + 'timeAdded', + 'isLive', + 'paid', + 'type' + ] - playlists.forEach(async (playlistData) => { - // 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 = [ - 'playlistName', - 'videos' - ] + const playlistObject = {} - const optionalKeys = [ - '_id', - 'protected', - 'removeOnWatched' - ] - - const requiredVideoKeys = [ - 'videoId', - 'title', - 'author', - 'authorId', - 'published', - 'lengthSeconds', - 'timeAdded', - 'isLive', - 'paid', - 'type' - ] - - const playlistObject = {} - - Object.keys(playlistData).forEach((key) => { - if (!requiredKeys.includes(key) && !optionalKeys.includes(key)) { - const message = `${this.$t('Settings.Data Settings.Unknown data key')}: ${key}` - this.showToast({ - message: message - }) - } else if (key === 'videos') { - const videoArray = [] - playlistData.videos.forEach((video) => { - let hasAllKeys = true - requiredVideoKeys.forEach((videoKey) => { - if (!Object.keys(video).includes(videoKey)) { - hasAllKeys = false - } - }) - - if (hasAllKeys) { - videoArray.push(video) - } - }) - - playlistObject[key] = videoArray - } else { - playlistObject[key] = playlistData[key] - } - }) - - const objectKeys = Object.keys(playlistObject) - - if ((objectKeys.length < requiredKeys.length) || playlistObject.videos.length === 0) { - const message = this.$t('Settings.Data Settings.Playlist insufficient data').replace('$', playlistData.playlistName) + Object.keys(playlistData).forEach((key) => { + if (!requiredKeys.includes(key) && !optionalKeys.includes(key)) { + const message = `${this.$t('Settings.Data Settings.Unknown data key')}: ${key}` this.showToast({ message: message }) - } else { - const existingPlaylist = this.allPlaylists.find((playlist) => { - return playlist.playlistName === playlistObject.playlistName - }) - - if (existingPlaylist !== undefined) { - playlistObject.videos.forEach((video) => { - const existingVideo = existingPlaylist.videos.find((x) => { - return x.videoId === video.videoId - }) - - if (existingVideo === undefined) { - const payload = { - playlistName: existingPlaylist.playlistName, - videoData: video - } - - this.addVideo(payload) + } else if (key === 'videos') { + const videoArray = [] + playlistData.videos.forEach((video) => { + let hasAllKeys = true + requiredVideoKeys.forEach((videoKey) => { + if (!Object.keys(video).includes(videoKey)) { + hasAllKeys = false } }) - } else { - this.addPlaylist(playlistObject) - } + + if (hasAllKeys) { + videoArray.push(video) + } + }) + + playlistObject[key] = videoArray + } else { + playlistObject[key] = playlistData[key] } }) - this.showToast({ - message: this.$t('Settings.Data Settings.All playlists has been successfully imported') - }) + const objectKeys = Object.keys(playlistObject) + + if ((objectKeys.length < requiredKeys.length) || playlistObject.videos.length === 0) { + const message = this.$t('Settings.Data Settings.Playlist insufficient data').replace('$', playlistData.playlistName) + this.showToast({ + message: message + }) + } else { + const existingPlaylist = this.allPlaylists.find((playlist) => { + return playlist.playlistName === playlistObject.playlistName + }) + + if (existingPlaylist !== undefined) { + playlistObject.videos.forEach((video) => { + const existingVideo = existingPlaylist.videos.find((x) => { + return x.videoId === video.videoId + }) + + if (existingVideo === undefined) { + const payload = { + playlistName: existingPlaylist.playlistName, + videoData: video + } + + this.addVideo(payload) + } + }) + } else { + this.addPlaylist(playlistObject) + } + } + }) + + this.showToast({ + message: this.$t('Settings.Data Settings.All playlists has been successfully imported') }) }, @@ -1344,6 +1338,7 @@ export default Vue.extend({ 'getRandomColor', 'calculateColorLuminance', 'showOpenDialog', + 'readFileFromDialog', 'showSaveDialog', 'getUserDataPath', 'addPlaylist', diff --git a/src/renderer/components/data-settings/data-settings.vue b/src/renderer/components/data-settings/data-settings.vue index c4b33595..ae9fa730 100644 --- a/src/renderer/components/data-settings/data-settings.vue +++ b/src/renderer/components/data-settings/data-settings.vue @@ -12,6 +12,7 @@ @click="showImportSubscriptionsPrompt = true" /> diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index 5934d103..16fb854f 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -395,9 +395,66 @@ const actions = { return (await invokeIRC(context, IpcChannels.GET_SYSTEM_LOCALE, webCbk)) || 'en-US' }, + /** + * @param {Object} response the response from `showOpenDialog` + * @param {Number} index which file to read (defaults to the first in the response) + * @returns the text contents of the selected file + */ + async readFileFromDialog(context, { response, index = 0 }) { + return await new Promise((resolve, reject) => { + if (process.env.IS_ELECTRON) { + // if this is Electron, use fs + fs.readFile(response.filePaths[index], (err, data) => { + if (err) { + reject(err) + return + } + resolve(new TextDecoder('utf-8').decode(data)) + }) + } else { + // if this is web, use FileReader + try { + const reader = new FileReader() + reader.onload = function (file) { + resolve(file.currentTarget.result) + } + reader.readAsText(response.files[index]) + } catch (exception) { + reject(exception) + } + } + }) + }, + async showOpenDialog (context, options) { - // TODO: implement showOpenDialog web compatible callback - const webCbk = () => null + const webCbk = () => { + return new Promise((resolve) => { + const fileInput = document.createElement('input') + fileInput.setAttribute('type', 'file') + if (options?.filters[0]?.extensions !== undefined) { + // this will map the given extensions from the options to the accept attribute of the input + fileInput.setAttribute('accept', options.filters[0].extensions.map((extension) => { return `.${extension}` }).join(', ')) + } + fileInput.onchange = () => { + const files = Array.from(fileInput.files) + resolve({ canceled: false, files }) + delete fileInput.onchange + } + const listenForEnd = () => { + window.removeEventListener('focus', listenForEnd) + // 1 second timeout on the response from the file picker to prevent awaiting forever + setTimeout(() => { + if (fileInput.files.length === 0 && typeof fileInput.onchange === 'function') { + // if there are no files and the onchange has not been triggered, the file-picker was canceled + resolve({ canceled: true }) + delete fileInput.onchange + } + }, 1000) + } + window.addEventListener('focus', listenForEnd) + fileInput.click() + }) + } return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options) },