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

View File

@ -307,9 +307,63 @@ const actions = {
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) {
// TODO: implement showSaveDialog web compatible callback
const webCbk = () => null
const webCbk = async () => {
// 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)
},