Implenting `showSaveDialog` in web (#2741)

* Implementing the web callback for `showSaveDialog`

Using the native filesystem API when it is available

* Adding the `writeFileFromDialog` function

It normalizes the behavior of writing to a file handle
given by a file picker between web and electron

* Refactoring `exportFreeTubeSubscriptions`

Now, it should use the profile data stored in memory
rather than reading it from the file system (which may not
be present in web browsers), and it should also work in web
builds

* Refactoring exportYouTubeSubscriptions

Using the `writeFileFromDialog` function instead of `fs`

* Refactoring the rest of the subscription exports

Using the `writeFileFromDialog` function instead of `fs`

* Refactoring exportHistory

- Using the historyCache stored in memory instead of
reading from `fs` to get the history data
- Using `writeFileFromDialog` instead of `fs.writeFile`

* Refactoring exportPlaylists

Using `writeFileFromDialog` instead of `fs`

* Removing no longer needed `fs` import

* Removing something I was using to debug

* Fixing issue with mime types non populating in save picker

* Adding back an unintentionally removed line
This commit is contained in:
Emma 2022-10-19 01:50:21 -04:00 committed by GitHub
parent d39512db0a
commit 41b3af033b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 121 additions and 106 deletions

View File

@ -8,13 +8,10 @@ import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue' import FtPrompt from '../ft-prompt/ft-prompt.vue'
import { MAIN_PROFILE_ID } from '../../../constants' import { MAIN_PROFILE_ID } from '../../../constants'
import fs from 'fs'
import { opmlToJSON } from 'opml-to-json' import { opmlToJSON } from 'opml-to-json'
import ytch from 'yt-channel-info' import ytch from 'yt-channel-info'
import { calculateColorLuminance, copyToClipboard, getRandomColor, showToast } from '../../helpers/utils' import { calculateColorLuminance, copyToClipboard, getRandomColor, showToast } from '../../helpers/utils'
// FIXME: Missing web logic branching
export default Vue.extend({ export default Vue.extend({
name: 'DataSettings', name: 'DataSettings',
components: { components: {
@ -60,6 +57,9 @@ export default Vue.extend({
allPlaylists: function () { allPlaylists: function () {
return this.$store.getters.getAllPlaylists return this.$store.getters.getAllPlaylists
}, },
historyCache: function () {
return this.$store.getters.getHistoryCache
},
exportSubscriptionsPromptNames: function () { exportSubscriptionsPromptNames: function () {
const exportFreeTube = this.$t('Settings.Data Settings.Export FreeTube') const exportFreeTube = this.$t('Settings.Data Settings.Export FreeTube')
const exportYouTube = this.$t('Settings.Data Settings.Export YouTube') const exportYouTube = this.$t('Settings.Data Settings.Export YouTube')
@ -487,9 +487,9 @@ export default Vue.extend({
}, },
exportFreeTubeSubscriptions: async function () { exportFreeTubeSubscriptions: async function () {
await this.compactProfiles() const subscriptionsDb = this.profileList.map((profile) => {
const userData = await this.getUserDataPath() return JSON.stringify(profile)
const subscriptionsDb = `${userData}/profiles.db` }).join('\n') + '\n'// a trailing line is expected
const date = new Date().toISOString().split('T')[0] const date = new Date().toISOString().split('T')[0]
const exportFileName = 'freetube-subscriptions-' + date + '.db' const exportFileName = 'freetube-subscriptions-' + date + '.db'
@ -508,26 +508,14 @@ export default Vue.extend({
// User canceled the save dialog // User canceled the save dialog
return return
} }
try {
const filePath = response.filePath await this.writeFileFromDialog({ response, content: subscriptionsDb })
} catch (writeErr) {
fs.readFile(subscriptionsDb, (readErr, data) => { const message = this.$t('Settings.Data Settings.Unable to read file')
if (readErr) { showToast(`${message}: ${writeErr}`)
const message = this.$t('Settings.Data Settings.Unable to read file') return
showToast(`${message}: ${readErr}`) }
return showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
}
fs.writeFile(filePath, data, (writeErr) => {
if (writeErr) {
const message = this.$t('Settings.Data Settings.Unable to write file')
showToast(`${message}: ${writeErr}`)
return
}
showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
})
})
}, },
exportYouTubeSubscriptions: async function () { exportYouTubeSubscriptions: async function () {
@ -586,17 +574,14 @@ export default Vue.extend({
return return
} }
const filePath = response.filePath try {
await this.writeFileFromDialog({ response, content: JSON.stringify(subscriptionsObject) })
fs.writeFile(filePath, JSON.stringify(subscriptionsObject), (writeErr) => { } catch (writeErr) {
if (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file')
const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`)
showToast(`${message}: ${writeErr}`) return
return }
} showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
})
}, },
exportOpmlYouTubeSubscriptions: async function () { exportOpmlYouTubeSubscriptions: async function () {
@ -634,17 +619,14 @@ export default Vue.extend({
return return
} }
const filePath = response.filePath try {
await this.writeFileFromDialog({ response, content: opmlData })
fs.writeFile(filePath, opmlData, (writeErr) => { } catch (writeErr) {
if (writeErr) { const message = this.$t('Settings.Data Settings.Unable to write file')
const message = this.$t('Settings.Data Settings.Unable to write file') showToast(`${message}: ${writeErr}`)
showToast(`${message}: ${writeErr}`) return
return }
} showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
})
}, },
exportCsvYouTubeSubscriptions: async function () { exportCsvYouTubeSubscriptions: async function () {
@ -676,16 +658,14 @@ export default Vue.extend({
return return
} }
const filePath = response.filePath try {
fs.writeFile(filePath, exportText, (writeErr) => { await this.writeFileFromDialog({ response, content: exportText })
if (writeErr) { } catch (writeErr) {
const message = this.$t('Settings.Data Settings.Unable to write file') const message = this.$t('Settings.Data Settings.Unable to write file')
showToast(`${message}: ${writeErr}`) showToast(`${message}: ${writeErr}`)
return return
} }
showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
})
}, },
exportNewPipeSubscriptions: async function () { exportNewPipeSubscriptions: async function () {
@ -724,18 +704,14 @@ export default Vue.extend({
// User canceled the save dialog // User canceled the save dialog
return return
} }
try {
const filePath = response.filePath await this.writeFileFromDialog({ response, content: JSON.stringify(newPipeObject) })
} catch (writeErr) {
fs.writeFile(filePath, JSON.stringify(newPipeObject), (writeErr) => { const message = this.$t('Settings.Data Settings.Unable to write file')
if (writeErr) { showToast(`${message}: ${writeErr}`)
const message = this.$t('Settings.Data Settings.Unable to write file') return
showToast(`${message}: ${writeErr}`) }
return showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
}
showToast(this.$t('Settings.Data Settings.Subscriptions have been successfully exported'))
})
}, },
importHistory: async function () { importHistory: async function () {
@ -807,9 +783,9 @@ export default Vue.extend({
}, },
exportHistory: async function () { exportHistory: async function () {
await this.compactHistory() const historyDb = this.historyCache.map((historyEntry) => {
const userData = await this.getUserDataPath() return JSON.stringify(historyEntry)
const historyDb = `${userData}/history.db` }).join('\n') + '\n'
const date = new Date().toISOString().split('T')[0] const date = new Date().toISOString().split('T')[0]
const exportFileName = 'freetube-history-' + date + '.db' const exportFileName = 'freetube-history-' + date + '.db'
@ -829,25 +805,13 @@ export default Vue.extend({
return return
} }
const filePath = response.filePath try {
await this.writeFileFromDialog({ response, content: historyDb })
fs.readFile(historyDb, (readErr, data) => { } catch (writeErr) {
if (readErr) { const message = this.$t('Settings.Data Settings.Unable to write file')
const message = this.$t('Settings.Data Settings.Unable to read file') showToast(`${message}: ${writeErr}`)
showToast(`${message}: ${readErr}`) }
return showToast(this.$t('Settings.Data Settings.All watched history has been successfully exported'))
}
fs.writeFile(filePath, data, (writeErr) => {
if (writeErr) {
const message = this.$t('Settings.Data Settings.Unable to write file')
showToast(`${message}: ${writeErr}`)
return
}
showToast(this.$t('Settings.Data Settings.All watched history has been successfully exported'))
})
})
}, },
importPlaylists: async function () { importPlaylists: async function () {
@ -983,18 +947,14 @@ export default Vue.extend({
// User canceled the save dialog // User canceled the save dialog
return return
} }
try {
const filePath = response.filePath await this.writeFileFromDialog({ response, content: JSON.stringify(this.allPlaylists) })
} catch (writeErr) {
fs.writeFile(filePath, JSON.stringify(this.allPlaylists), (writeErr) => { const message = this.$t('Settings.Data Settings.Unable to write file')
if (writeErr) { showToast(`${message}: ${writeErr}`)
const message = this.$t('Settings.Data Settings.Unable to write file') return
showToast(`${message}: ${writeErr}`) }
return showToast(`${this.$t('Settings.Data Settings.All playlists has been successfully exported')}`)
}
showToast(this.$t('Settings.Data Settings.All playlists has been successfully exported'))
})
}, },
convertOldFreeTubeFormatToNew(oldData) { convertOldFreeTubeFormatToNew(oldData) {
@ -1143,6 +1103,7 @@ export default Vue.extend({
'showOpenDialog', 'showOpenDialog',
'readFileFromDialog', 'readFileFromDialog',
'showSaveDialog', 'showSaveDialog',
'writeFileFromDialog',
'getUserDataPath', 'getUserDataPath',
'addPlaylist', 'addPlaylist',
'addVideo' 'addVideo'

View File

@ -307,9 +307,63 @@ const actions = {
return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options) return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options)
}, },
/**
* Write to a file picked out from the `showSaveDialog` picker
* @param {Object} response the response from `showSaveDialog`
* @param {String} content the content to be written to the file selected by the dialog
*/
async writeFileFromDialog (context, { response, content }) {
if (process.env.IS_ELECTRON) {
return await new Promise((resolve, reject) => {
const { filePath } = response
fs.writeFile(filePath, content, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
} else {
if ('showOpenFilePicker' in window) {
const { handle } = response
const writableStream = await handle.createWritable()
await writableStream.write(content)
await writableStream.close()
} else {
// If the native filesystem api is not available,
const { filePath } = response
const filename = filePath.split('/').at(-1)
const a = document.createElement('a')
const url = URL.createObjectURL(new Blob([content], { type: 'application/octet-stream' }))
a.setAttribute('href', url)
a.setAttribute('download', encodeURI(filename))
a.click()
}
}
},
async showSaveDialog (context, options) { async showSaveDialog (context, options) {
// TODO: implement showSaveDialog web compatible callback const webCbk = async () => {
const webCbk = () => null // If the native filesystem api is available
if ('showSaveFilePicker' in window) {
return {
canceled: false,
handle: await window.showSaveFilePicker({
suggestedName: options.defaultPath.split('/').at(-1),
types: options?.filters[0]?.extensions?.map((extension) => {
return {
accept: {
'application/octet-stream': '.' + extension
}
}
})
})
}
} else {
return { canceled: false, filePath: options.defaultPath }
}
}
return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, options) return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, options)
}, },