Skip to content

Commit

Permalink
Switch the data import to the Web File System API (#6222)
Browse files Browse the repository at this point in the history
  • Loading branch information
absidue authored Nov 26, 2024
1 parent d1c804d commit 93f8832
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 116 deletions.
145 changes: 73 additions & 72 deletions src/renderer/components/data-settings/data-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import {
deepCopy,
escapeHTML,
getTodayDateStrLocalTimezone,
readFileFromDialog,
showOpenDialog,
readFileWithPicker,
showSaveDialog,
showToast,
writeFileFromDialog,
} from '../../helpers/utils'

const IMPORT_DIRECTORY_ID = 'data-settings-import'
const IMPORT_START_IN_DIRECTORY = 'downloads'

export default defineComponent({
name: 'DataSettings',
components: {
Expand Down Expand Up @@ -81,44 +83,45 @@ export default defineComponent({
},

importSubscriptions: async function () {
const options = {
properties: ['openFile'],
filters: [
{
name: this.$t('Settings.Data Settings.Subscription File'),
extensions: ['db', 'csv', 'json', 'opml', 'xml']
}
]
}

const response = await showOpenDialog(options)
if (response.canceled || response.filePaths?.length === 0) {
return
}
let textDecode
let response
try {
textDecode = await readFileFromDialog(response)
response = await readFileWithPicker(
this.$t('Settings.Data Settings.Subscription File'),
{
'application/x-freetube-db': '.db',
'text/csv': '.csv',
'application/json': '.json',
'application/xml': ['.xml', '.opml']
},
IMPORT_DIRECTORY_ID,
IMPORT_START_IN_DIRECTORY
)
} catch (err) {
const message = this.$t('Settings.Data Settings.Unable to read file')
showToast(`${message}: ${err}`)
return
}
response.filePaths.forEach(filePath => {
if (filePath.endsWith('.csv')) {
this.importCsvYouTubeSubscriptions(textDecode)
} else if (filePath.endsWith('.db')) {
this.importFreeTubeSubscriptions(textDecode)
} else if (filePath.endsWith('.opml') || filePath.endsWith('.xml')) {
this.importOpmlYouTubeSubscriptions(textDecode)
} else if (filePath.endsWith('.json')) {
textDecode = JSON.parse(textDecode)
if (textDecode.subscriptions) {
this.importNewPipeSubscriptions(textDecode)
} else {
this.importYouTubeSubscriptions(textDecode)
}

if (response === null) {
return
}

const { filename, content } = response

if (filename.endsWith('.csv')) {
this.importCsvYouTubeSubscriptions(content)
} else if (filename.endsWith('.db')) {
this.importFreeTubeSubscriptions(content)
} else if (filename.endsWith('.opml') || filename.endsWith('.xml')) {
this.importOpmlYouTubeSubscriptions(content)
} else if (filename.endsWith('.json')) {
const jsonContent = JSON.parse(content)
if (jsonContent.subscriptions) {
this.importNewPipeSubscriptions(jsonContent)
} else {
this.importYouTubeSubscriptions(jsonContent)
}
})
}
},

importFreeTubeSubscriptions: function (textDecode) {
Expand Down Expand Up @@ -587,36 +590,34 @@ export default defineComponent({
},

importHistory: async function () {
const options = {
properties: ['openFile'],
filters: [
{
name: this.$t('Settings.Data Settings.History File'),
extensions: ['db', 'json']
}
]
}

const response = await showOpenDialog(options)
if (response.canceled || response.filePaths?.length === 0) {
return
}
let textDecode
let response
try {
textDecode = await readFileFromDialog(response)
response = await readFileWithPicker(
this.$t('Settings.Data Settings.History File'),
{
'application/x-freetube-db': '.db',
'application/json': '.json'
},
IMPORT_DIRECTORY_ID,
IMPORT_START_IN_DIRECTORY
)
} catch (err) {
const message = this.$t('Settings.Data Settings.Unable to read file')
showToast(`${message}: ${err}`)
return
}

response.filePaths.forEach(filePath => {
if (filePath.endsWith('.db')) {
this.importFreeTubeHistory(textDecode.split('\n'))
} else if (filePath.endsWith('.json')) {
this.importYouTubeHistory(JSON.parse(textDecode))
}
})
if (response === null) {
return
}

const { filename, content } = response

if (filename.endsWith('.db')) {
this.importFreeTubeHistory(content.split('\n'))
} else if (filename.endsWith('.json')) {
this.importYouTubeHistory(JSON.parse(content))
}
},

async importFreeTubeHistory(textDecode) {
Expand Down Expand Up @@ -791,28 +792,28 @@ export default defineComponent({
},

importPlaylists: async function () {
const options = {
properties: ['openFile'],
filters: [
{
name: this.$t('Settings.Data Settings.Playlist File'),
extensions: ['db']
}
]
}

const response = await showOpenDialog(options)
if (response.canceled || response.filePaths?.length === 0) {
return
}
let data
let response
try {
data = await readFileFromDialog(response)
response = await readFileWithPicker(
this.$t('Settings.Data Settings.Playlist File'),
{
'application/x-freetube-db': '.db'
},
IMPORT_DIRECTORY_ID,
IMPORT_START_IN_DIRECTORY
)
} catch (err) {
const message = this.$t('Settings.Data Settings.Unable to read file')
showToast(`${message}: ${err}`)
return
}

if (response === null) {
return
}

let data = response.content

let playlists = null

// for the sake of backwards compatibility,
Expand Down
109 changes: 65 additions & 44 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,67 +272,88 @@ export function openInternalPath({ path, query = {}, doCreateNewWindow, searchQu
}
}

export async function showOpenDialog (options) {
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
return await ipcRenderer.invoke(IpcChannels.SHOW_OPEN_DIALOG, options)
/**
* @param {string} fileTypeDescription
* @param {{[key: string]: string | string[]}} acceptedTypes
* @param {string} [rememberDirectoryId]
* @param {'desktop' | 'documents' | 'downloads' | 'music' | 'pictures' | 'videos'} [startInDirectory]
* @returns {Promise<{ content: string, filename: string } | null>}
*/
export async function readFileWithPicker(
fileTypeDescription,
acceptedTypes,
rememberDirectoryId,
startInDirectory
) {
let file

// Only supported in Electron and desktop Chromium browsers
// https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#browser_compatibility
// As we know it is supported in Electron, adding the build flag means we can skip the runtime check in Electron
// and allow terser to remove the unused else block
if (process.env.IS_ELECTRON || 'showOpenFilePicker' in window) {
try {
/** @type {FileSystemFileHandle[]} */
const [handle] = await window.showOpenFilePicker({
excludeAcceptAllOption: true,
multiple: false,
id: rememberDirectoryId,
startIn: startInDirectory,
types: [{
description: fileTypeDescription,
accept: acceptedTypes
}],
})

file = await handle.getFile()
} catch (error) {
// user pressed cancel in the file picker
if (error.name === 'AbortError') {
return null
}

throw error
}
} else {
return await new Promise((resolve) => {
/** @type {File|null} */
const fallbackFile = await new Promise((resolve) => {
const joinedExtensions = Object.values(acceptedTypes)
.flat()
.join(',')

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.setAttribute('accept', joinedExtensions)
fileInput.onchange = () => {
const files = Array.from(fileInput.files)
resolve({ canceled: false, files, filePaths: files.map(({ name }) => { return name }) })
delete fileInput.onchange
resolve(fileInput.files[0])
fileInput.onchange = null
}

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
resolve(null)
fileInput.onchange = null
}
}, 1000)
}
window.addEventListener('focus', listenForEnd)
window.addEventListener('focus', listenForEnd, { once: true })
fileInput.click()
})
}
}

/**
* @param {object} response the response from `showOpenDialog`
* @param {number} index which file to read (defaults to the first in the response)
* @returns {string} the text contents of the selected file
*/
export function readFileFromDialog(response, index = 0) {
return new Promise((resolve, reject) => {
if (process.env.IS_ELECTRON) {
// if this is Electron, use fs
fs.readFile(response.filePaths[index])
.then(data => {
resolve(new TextDecoder('utf-8').decode(data))
})
.catch(reject)
} 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)
}
if (fallbackFile === null) {
return null
}
})

file = fallbackFile
}

return {
content: await file.text(),
filename: file.name
}
}

/**
Expand Down

0 comments on commit 93f8832

Please sign in to comment.