fix(es6): Convert FileDownloadStore to JS

This commit is contained in:
Juan Tejada 2016-11-01 18:12:24 -07:00
parent 20b6a6e070
commit 5fa379bccf
3 changed files with 440 additions and 346 deletions

View file

@ -4,10 +4,10 @@ path = require 'path'
NylasAPI = require '../../src/flux/nylas-api'
File = require('../../src/flux/models/file').default
Message = require('../../src/flux/models/message').default
FileDownloadStore = require '../../src/flux/stores/file-download-store'
FileDownloadStore = require('../../src/flux/stores/file-download-store').default
{Download} = require('../../src/flux/stores/file-download-store')
AccountStore = require '../../src/flux/stores/account-store'
Download = FileDownloadStore.Download
describe "FileDownloadStore.Download", ->
beforeEach ->
@ -113,13 +113,13 @@ describe "FileDownloadStore", ->
FileDownloadStore._checkForDownloadedFile(f).then (downloaded) ->
expect(downloaded).toBe(false)
describe "_newMailReceived", ->
describe "_onNewMailReceived", ->
it "should fetch attachments if the setting is on-receive", ->
spyOn(FileDownloadStore, '_fetch')
spyOn(NylasEnv.config, 'get').andCallFake (key) ->
return 'on-receive' if key is 'core.attachments.downloadPolicy'
return null
FileDownloadStore._newMailReceived(message: [new Message(files: [new File()])])
FileDownloadStore._onNewMailReceived(message: [new Message(files: [new File()])])
expect(FileDownloadStore._fetch).toHaveBeenCalled()
it "should not fetch attachments otherwise", ->
@ -127,7 +127,7 @@ describe "FileDownloadStore", ->
spyOn(NylasEnv.config, 'get').andCallFake (key) ->
return 'on-read' if key is 'core.attachments.downloadPolicy'
return null
FileDownloadStore._newMailReceived(message: [new Message(files: [new File()])])
FileDownloadStore._onNewMailReceived(message: [new Message(files: [new File()])])
expect(FileDownloadStore._fetch).not.toHaveBeenCalled()
describe "_runDownload", ->

View file

@ -1,341 +0,0 @@
os = require 'os'
fs = require 'fs'
path = require 'path'
{remote, shell} = require 'electron'
mkdirp = require 'mkdirp'
Utils = require '../models/utils'
Reflux = require 'reflux'
_ = require 'underscore'
Actions = require('../actions').default
progress = require 'request-progress'
NylasAPI = require '../nylas-api'
RegExpUtils = require '../../regexp-utils'
Promise.promisifyAll(fs)
mkdirpAsync = Promise.promisify(mkdirp)
State =
Unstarted: 'unstarted'
Downloading: 'downloading'
Finished: 'finished'
Failed: 'failed'
class Download
@State: State
constructor: ({@accountId, @fileId, @targetPath, @filename, @filesize, @progressCallback}) ->
if not @accountId
throw new Error("Download.constructor: You must provide a non-empty accountId.")
if not @filename or @filename.length is 0
throw new Error("Download.constructor: You must provide a non-empty filename.")
if not @fileId
throw new Error("Download.constructor: You must provide a fileID to download.")
if not @targetPath
throw new Error("Download.constructor: You must provide a target path to download.")
@percent = 0
@promise = null
@state = State.Unstarted
@
# We need to pass a plain object so we can have fresh references for the
# React views while maintaining the single object with the running
# request.
data: -> Object.freeze _.clone
state: @state
fileId: @fileId
percent: @percent
filename: @filename
filesize: @filesize
targetPath: @targetPath
run: ->
# If run has already been called, return the existing promise. Never
# initiate multiple downloads for the same file
return @promise if @promise
# Note: we must resolve or reject with `this`
@promise = new Promise (resolve, reject) =>
stream = fs.createWriteStream(@targetPath)
@state = State.Downloading
onFailed = (err) =>
@request = null
stream.end()
@state = State.Failed
if fs.existsSync(@targetPath)
fs.unlinkSync(@targetPath)
reject(err)
onSuccess = =>
@request = null
stream.end()
@state = State.Finished
@percent = 100
resolve(@)
NylasAPI.makeRequest
json: false
path: "/files/#{@fileId}/download"
accountId: @accountId
encoding: null # Tell `request` not to parse the response data
started: (req) =>
@request = req
progress(@request, {throtte: 250})
.on "progress", (progress) =>
@percent = progress.percent
@progressCallback()
# This is a /socket/ error event, not an HTTP error event. It fires
# when the conn is dropped, user if offline, but not on HTTP status codes.
# It is sometimes called in place of "end", not before or after.
.on("error", onFailed)
.on "end", =>
return if @state is State.Failed
statusCode = @request.response?.statusCode
if [200, 202, 204].includes(statusCode)
onSuccess()
else
onFailed(new Error("Server returned a #{statusCode}"))
.pipe(stream)
ensureClosed: ->
@request?.abort()
module.exports =
FileDownloadStore = Reflux.createStore
init: ->
@listenTo Actions.fetchFile, @_fetch
@listenTo Actions.fetchAndOpenFile, @_fetchAndOpen
@listenTo Actions.fetchAndSaveFile, @_fetchAndSave
@listenTo Actions.fetchAndSaveAllFiles, @_fetchAndSaveAll
@listenTo Actions.abortFetchFile, @_abortFetchFile
@listenTo Actions.didPassivelyReceiveNewModels, @_newMailReceived
@_downloads = {}
@_downloadDirectory = path.join(NylasEnv.getConfigDirPath(), 'downloads')
mkdirp(@_downloadDirectory)
######### PUBLIC #######################################################
# Returns a path on disk for saving the file. Note that we must account
# for files that don't have a name and avoid returning <downloads/dir/"">
# which causes operations to happen on the directory (badness!)
#
pathForFile: (file) ->
return undefined unless file
path.join(@_downloadDirectory, file.id, file.safeDisplayName())
downloadDataForFile: (fileId) ->
@_downloads[fileId]?.data()
# Returns a hash of download objects keyed by fileId
#
downloadDataForFiles: (fileIds=[]) ->
downloadData = {}
fileIds.forEach (fileId) =>
downloadData[fileId] = @downloadDataForFile(fileId)
return downloadData
########### PRIVATE ####################################################
_newMailReceived: (incoming) ->
if NylasEnv.config.get('core.attachments.downloadPolicy') is 'on-receive'
return unless incoming['message']
for message in incoming['message']
for file in message.files
@_fetch(file)
# Returns a promise with a Download object, allowing other actions to be
# daisy-chained to the end of the download operation.
_runDownload: (file) ->
targetPath = @pathForFile(file)
# is there an existing download for this file? If so,
# return that promise so users can chain to the end of it.
download = @_downloads[file.id]
return download.run() if download
# create a new download for this file
download = new Download
accountId: file.accountId
fileId: file.id
filesize: file.size
filename: file.displayName()
targetPath: targetPath
progressCallback: => @trigger()
# Do we actually need to queue and run the download? Queuing a download
# for an already-downloaded file has side-effects, like making the UI
# flicker briefly.
@_prepareFolder(file).then =>
@_checkForDownloadedFile(file).then (alreadyHaveFile) =>
if alreadyHaveFile
# If we have the file, just resolve with a resolved download representing the file.
download.promise = Promise.resolve()
download.state = State.Finished
return Promise.resolve(download)
else
@_downloads[file.id] = download
@trigger()
return download.run().finally =>
download.ensureClosed()
if download.state is State.Failed
delete @_downloads[file.id]
@trigger()
# Returns a promise that resolves with true or false. True if the file has
# been downloaded, false if it should be downloaded.
#
_checkForDownloadedFile: (file) ->
fs.statAsync(@pathForFile(file)).catch (err) =>
return Promise.resolve(false)
.then (stats) =>
return Promise.resolve(stats.size >= file.size)
# Checks that the folder for the download is ready. Returns a promise that
# resolves when the download directory for the file has been created.
#
_prepareFolder: (file) ->
targetFolder = path.join(@_downloadDirectory, file.id)
fs.statAsync(targetFolder).catch =>
mkdirpAsync(targetFolder)
_fetch: (file) ->
@_runDownload(file)
.catch(@_catchFSErrors)
.catch (error) ->
# Passively ignore
_fetchAndOpen: (file) ->
@_runDownload(file).then (download) ->
shell.openItem(download.targetPath)
.catch(@_catchFSErrors)
.catch (error) =>
@_presentError({file, error})
_saveDownload: (download, savePath) =>
return new Promise (resolve, reject) =>
stream = fs.createReadStream(download.targetPath)
stream.pipe(fs.createWriteStream(savePath))
stream.on 'error', (err) -> reject(err)
stream.on 'end', -> resolve()
_fetchAndSave: (file) ->
defaultPath = @_defaultSavePath(file)
defaultExtension = path.extname(defaultPath)
NylasEnv.showSaveDialog {defaultPath}, (savePath) =>
return unless savePath
newDownloadDirectory = path.dirname(savePath)
saveExtension = path.extname(savePath)
didLoseExtension = defaultExtension isnt '' and saveExtension is ''
if didLoseExtension
savePath = savePath + defaultExtension
@_runDownload(file)
.then (download) => @_saveDownload(download, savePath)
.then =>
if NylasEnv.savedState.lastDownloadDirectory isnt newDownloadDirectory
shell.showItemInFolder(savePath)
NylasEnv.savedState.lastDownloadDirectory = newDownloadDirectory
.catch(@_catchFSErrors)
.catch (error) =>
@_presentError({file, error})
_fetchAndSaveAll: (files) ->
defaultPath = @_defaultSaveDir()
options = {
defaultPath,
title: 'Save Into...',
buttonLabel: 'Download All',
properties: ['openDirectory', 'createDirectory'],
}
return new Promise (resolve, reject) =>
NylasEnv.showOpenDialog options, (selected) =>
return unless selected
dirPath = selected[0]
return unless dirPath
NylasEnv.savedState.lastDownloadDirectory = dirPath
lastSavePaths = []
savePromises = files.map (file) =>
savePath = path.join(dirPath, file.safeDisplayName())
@_runDownload(file)
.then (download) => @_saveDownload(download, savePath)
.then ->
lastSavePaths.push(savePath)
Promise.all(savePromises)
.then =>
shell.showItemInFolder(lastSavePaths[0]) if lastSavePaths.length > 0
resolve(lastSavePaths)
.catch(@_catchFSErrors)
.catch =>
@_presentError(file)
_abortFetchFile: (file) ->
download = @_downloads[file.id]
return unless download
download.ensureClosed()
@trigger()
downloadPath = @pathForFile(file)
fs.exists downloadPath, (exists) ->
fs.unlink(downloadPath) if exists
_defaultSaveDir: ->
if process.platform is 'win32'
home = process.env.USERPROFILE
else
home = process.env.HOME
downloadDir = path.join(home, 'Downloads')
if not fs.existsSync(downloadDir)
downloadDir = os.tmpdir()
if NylasEnv.savedState.lastDownloadDirectory
if fs.existsSync(NylasEnv.savedState.lastDownloadDirectory)
downloadDir = NylasEnv.savedState.lastDownloadDirectory
return downloadDir
_defaultSavePath: (file) ->
downloadDir = @_defaultSaveDir()
path.join(downloadDir, file.safeDisplayName())
_presentError: ({file, error} = {}) ->
name = if file then file.displayName() else "one or more files"
errorString = if error then error.toString() else ""
remote.dialog.showMessageBox
type: 'warning'
message: "Download Failed"
detail: "Unable to download #{name}. Check your network connection and try again. #{errorString}"
buttons: ["OK"]
_catchFSErrors: (error) ->
message = null
if error.code in ['EPERM', 'EMFILE', 'EACCES']
message = "N1 could not save an attachment. Check that permissions are set correctly and try restarting N1 if the issue persists."
if error.code in ['ENOSPC']
message = "N1 could not save an attachment because you have run out of disk space."
if message
remote.dialog.showMessageBox
type: 'warning'
message: "Download Failed"
detail: "#{message}\n\n#{error.message}"
buttons: ["OK"]
return Promise.resolve()
else
return Promise.reject(error)
# Expose the Download class for our tests, and possibly for other things someday
FileDownloadStore.Download = Download

View file

@ -0,0 +1,435 @@
import _ from 'underscore';
import os from 'os';
import fs from 'fs';
import path from 'path';
import { remote, shell } from 'electron';
import mkdirp from 'mkdirp';
import progress from 'request-progress';
import NylasStore from 'nylas-store';
import Actions from '../actions';
import NylasAPI from '../nylas-api';
Promise.promisifyAll(fs);
const mkdirpAsync = Promise.promisify(mkdirp);
const State = {
Unstarted: 'unstarted',
Downloading: 'downloading',
Finished: 'finished',
Failed: 'failed',
};
export class Download {
static State = State
constructor({accountId, fileId, targetPath, filename, filesize, progressCallback}) {
this.accountId = accountId;
this.fileId = fileId;
this.targetPath = targetPath;
this.filename = filename;
this.filesize = filesize;
this.progressCallback = progressCallback;
if (!this.accountId) {
throw new Error("Download.constructor: You must provide a non-empty accountId.");
}
if (!this.filename || this.filename.length === 0) {
throw new Error("Download.constructor: You must provide a non-empty filename.");
}
if (!this.fileId) {
throw new Error("Download.constructor: You must provide a fileID to download.");
}
if (!this.targetPath) {
throw new Error("Download.constructor: You must provide a target path to download.");
}
this.percent = 0;
this.promise = null;
this.state = State.Unstarted;
}
// We need to pass a plain object so we can have fresh references for the
// React views while maintaining the single object with the running
// request.
data() {
return Object.freeze(_.clone({
state: this.state,
fileId: this.fileId,
percent: this.percent,
filename: this.filename,
filesize: this.filesize,
targetPath: this.targetPath,
}));
}
run() {
// If run has already been called, return the existing promise. Never
// initiate multiple downloads for the same file
if (this.promise) { return this.promise; }
// Note: we must resolve or reject with `this`
this.promise = new Promise((resolve, reject) => {
const stream = fs.createWriteStream(this.targetPath);
this.state = State.Downloading;
const onFailed = (err) => {
this.request = null;
stream.end();
this.state = State.Failed;
if (fs.existsSync(this.targetPath)) {
fs.unlinkSync(this.targetPath);
}
reject(err);
};
const onSuccess = () => {
this.request = null;
stream.end();
this.state = State.Finished;
this.percent = 100;
resolve(this);
};
NylasAPI.makeRequest({
json: false,
path: `/files/${this.fileId}/download`,
accountId: this.accountId,
encoding: null, // Tell `request` not to parse the response data
started: (req) => {
this.request = req;
return progress(this.request, {throtte: 250})
.on('progress', (prog) => {
this.percent = prog.percent;
this.progressCallback();
})
// This is a /socket/ error event, not an HTTP error event. It fires
// when the conn is dropped, user if offline, but not on HTTP status codes.
// It is sometimes called in place of "end", not before or after.
.on('error', onFailed)
.on('end', () => {
if (this.state === State.Failed) { return; }
const {response} = this.request
const statusCode = response ? response.statusCode : null;
if ([200, 202, 204].includes(statusCode)) {
onSuccess();
} else {
onFailed(new Error(`Server returned a ${statusCode}`));
}
})
.pipe(stream);
},
});
});
return this.promise
}
ensureClosed() {
if (this.request) {
this.request.abort()
}
}
}
class FileDownloadStore extends NylasStore {
constructor() {
super()
this.listenTo(Actions.fetchFile, this._fetch);
this.listenTo(Actions.fetchAndOpenFile, this._fetchAndOpen);
this.listenTo(Actions.fetchAndSaveFile, this._fetchAndSave);
this.listenTo(Actions.fetchAndSaveAllFiles, this._fetchAndSaveAll);
this.listenTo(Actions.abortFetchFile, this._abortFetchFile);
this.listenTo(Actions.didPassivelyReceiveNewModels, this._onNewMailReceived);
this._downloads = {};
this._downloadDirectory = path.join(NylasEnv.getConfigDirPath(), 'downloads');
mkdirp(this._downloadDirectory);
}
// Expose the Download class for our tests, and possibly for other things someday
get Download() {
return Download
}
// Returns a path on disk for saving the file. Note that we must account
// for files that don't have a name and avoid returning <downloads/dir/"">
// which causes operations to happen on the directory (badness!)
//
pathForFile(file) {
if (!file) { return null; }
return path.join(this._downloadDirectory, file.id, file.safeDisplayName());
}
downloadDataForFile(fileId) {
const download = this._downloads[fileId]
if (!download) { return null; }
return download.data()
}
// Returns a hash of download objects keyed by fileId
//
downloadDataForFiles(fileIds = []) {
const downloadData = {};
fileIds.forEach((fileId) => {
downloadData[fileId] = this.downloadDataForFile(fileId);
});
return downloadData;
}
_onNewMailReceived = (incoming) => {
if (NylasEnv.config.get('core.attachments.downloadPolicy') !== 'on-receive') {
return;
}
if (!incoming.message) { return; }
incoming.message.forEach((message) => {
message.files.forEach((file) => this._fetch(file));
})
}
// Returns a promise with a Download object, allowing other actions to be
// daisy-chained to the end of the download operation.
_runDownload(file) {
const targetPath = this.pathForFile(file);
// is there an existing download for this file? If so,
// return that promise so users can chain to the end of it.
let download = this._downloads[file.id];
if (download) { return download.run(); }
// create a new download for this file
download = new Download({
accountId: file.accountId,
fileId: file.id,
filesize: file.size,
filename: file.displayName(),
targetPath,
progressCallback: () => this.trigger(),
});
// Do we actually need to queue and run the download? Queuing a download
// for an already-downloaded file has side-effects, like making the UI
// flicker briefly.
return this._prepareFolder(file).then(() => {
return this._checkForDownloadedFile(file).then((alreadyHaveFile) => {
if (alreadyHaveFile) {
// If we have the file, just resolve with a resolved download representing the file.
download.promise = Promise.resolve();
download.state = State.Finished;
return Promise.resolve(download);
}
this._downloads[file.id] = download;
this.trigger();
return download.run().finally(() => {
download.ensureClosed();
if (download.state === State.Failed) {
delete this._downloads[file.id];
}
this.trigger();
});
});
});
}
_fetchPreview(download) {
}
// Returns a promise that resolves with true or false. True if the file has
// been downloaded, false if it should be downloaded.
//
_checkForDownloadedFile(file) {
return fs.statAsync(this.pathForFile(file))
.then((stats) => {
return Promise.resolve(stats.size >= file.size);
})
.catch(() => {
return Promise.resolve(false);
})
}
// Checks that the folder for the download is ready. Returns a promise that
// resolves when the download directory for the file has been created.
//
_prepareFolder(file) {
const targetFolder = path.join(this._downloadDirectory, file.id);
return fs.statAsync(targetFolder)
.catch(() => {
return mkdirpAsync(targetFolder);
});
}
_fetch = (file) => {
return this._runDownload(file)
.catch(this._catchFSErrors)
// Passively ignore
.catch(() => {});
}
_fetchAndOpen = (file) => {
return this._runDownload(file)
.then((download) => shell.openItem(download.targetPath))
.catch(this._catchFSErrors)
.catch((error) => {
return this._presentError({file, error});
});
}
_saveDownload = (download, savePath) => {
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(download.targetPath);
stream.pipe(fs.createWriteStream(savePath));
stream.on('error', err => reject(err));
stream.on('end', () => resolve());
});
}
_fetchAndSave = (file) => {
const defaultPath = this._defaultSavePath(file);
const defaultExtension = path.extname(defaultPath);
NylasEnv.showSaveDialog({defaultPath}, (savePath) => {
if (!savePath) { return; }
const saveExtension = path.extname(savePath);
const newDownloadDirectory = path.dirname(savePath);
const didLoseExtension = defaultExtension !== '' && saveExtension === '';
let actualSavePath = savePath
if (didLoseExtension) {
actualSavePath += defaultExtension;
}
this._runDownload(file)
.then((download) => this._saveDownload(download, actualSavePath))
.then(() => {
if (NylasEnv.savedState.lastDownloadDirectory !== newDownloadDirectory) {
shell.showItemInFolder(actualSavePath);
NylasEnv.savedState.lastDownloadDirectory = newDownloadDirectory;
}
})
.catch(this._catchFSErrors)
.catch(error => {
this._presentError({file, error});
});
});
}
_fetchAndSaveAll = (files) => {
const defaultPath = this._defaultSaveDir();
const options = {
defaultPath,
title: 'Save Into...',
buttonLabel: 'Download All',
properties: ['openDirectory', 'createDirectory'],
};
return new Promise((resolve) => {
NylasEnv.showOpenDialog(options, (selected) => {
if (!selected) { return; }
const dirPath = selected[0];
if (!dirPath) { return; }
NylasEnv.savedState.lastDownloadDirectory = dirPath;
const lastSavePaths = [];
const savePromises = files.map((file) => {
const savePath = path.join(dirPath, file.safeDisplayName());
return this._runDownload(file)
.then((download) => this._saveDownload(download, savePath))
.then(() => lastSavePaths.push(savePath));
});
Promise.all(savePromises)
.then(() => {
if (lastSavePaths.length > 0) {
shell.showItemInFolder(lastSavePaths[0]);
}
return resolve(lastSavePaths);
})
.catch(this._catchFSErrors)
.catch((error) => {
return this._presentError({error});
});
});
});
}
_abortFetchFile = (file) => {
const download = this._downloads[file.id];
if (!download) { return; }
download.ensureClosed();
this.trigger();
const downloadPath = this.pathForFile(file);
fs.exists(downloadPath, (exists) => {
if (exists) {
fs.unlink(downloadPath);
}
});
}
_defaultSaveDir() {
let home = ''
if (process.platform === 'win32') {
home = process.env.USERPROFILE;
} else {
home = process.env.HOME;
}
let downloadDir = path.join(home, 'Downloads');
if (!fs.existsSync(downloadDir)) {
downloadDir = os.tmpdir();
}
if (NylasEnv.savedState.lastDownloadDirectory) {
if (fs.existsSync(NylasEnv.savedState.lastDownloadDirectory)) {
downloadDir = NylasEnv.savedState.lastDownloadDirectory;
}
}
return downloadDir;
}
_defaultSavePath(file) {
const downloadDir = this._defaultSaveDir();
return path.join(downloadDir, file.safeDisplayName());
}
_presentError({file, error} = {}) {
const name = file ? file.displayName() : "one or more files";
const errorString = error ? error.toString() : "";
return remote.dialog.showMessageBox({
type: 'warning',
message: "Download Failed",
detail: `Unable to download ${name}. Check your network connection and try again. ${errorString}`,
buttons: ["OK"],
});
}
_catchFSErrors(error) {
let message = null;
if (['EPERM', 'EMFILE', 'EACCES'].includes(error.code)) {
message = "N1 could not save an attachment. Check that permissions are set correctly and try restarting N1 if the issue persists.";
}
if (['ENOSPC'].includes(error.code)) {
message = "N1 could not save an attachment because you have run out of disk space.";
}
if (message) {
remote.dialog.showMessageBox({
type: 'warning',
message: "Download Failed",
detail: `${message}\n\n${error.message}`,
buttons: ["OK"],
});
return Promise.resolve();
}
return Promise.reject(error);
}
}
export default new FileDownloadStore()