diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index caf61c42256be..c13adaf4b7927 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -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: { @@ -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) { @@ -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) { @@ -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, diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index 5d37084c8f009..34aa9d1d3155a 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -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 + } } /**