├── src ├── coffee │ ├── chrome_oauth_receiver.coffee │ ├── dropbox_chrome.coffee │ ├── options.coffee │ ├── options_view.coffee │ ├── download_controller.coffee │ ├── upload_controller.coffee │ ├── dropship_file.coffee │ ├── popup.coffee │ ├── event_page.coffee │ └── dropship_list.coffee ├── images │ ├── icon128.png │ ├── icon16.png │ ├── icon48.png │ ├── icon64.png │ ├── action19.png │ ├── action38.png │ ├── store_icon128.png │ └── icon.svg ├── font │ └── SourceSansPro │ │ ├── SourceSansPro-Bold.ttf │ │ ├── SourceSansPro-Black.ttf │ │ ├── SourceSansPro-Italic.ttf │ │ ├── SourceSansPro-Light.ttf │ │ ├── SourceSansPro-Regular.ttf │ │ ├── SourceSansPro-Semibold.ttf │ │ ├── SourceSansPro-BlackItalic.ttf │ │ ├── SourceSansPro-BoldItalic.ttf │ │ ├── SourceSansPro-ExtraLight.ttf │ │ ├── SourceSansPro-LightItalic.ttf │ │ ├── SourceSansPro-SemiboldItalic.ttf │ │ ├── SourceSansPro-ExtraLightItalic.ttf │ │ └── OFL.txt ├── html │ ├── chrome_oauth_receiver.html │ ├── options.html │ └── popup.html ├── manifest.json └── less │ ├── _fonts.less │ ├── options.less │ └── popup.less ├── .gitignore ├── package.json ├── LICENSE.txt ├── README.md └── Cakefile /src/coffee/chrome_oauth_receiver.coffee: -------------------------------------------------------------------------------- 1 | Dropbox.AuthDriver.ChromeExtension.oauthReceiver() 2 | -------------------------------------------------------------------------------- /src/images/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/icon128.png -------------------------------------------------------------------------------- /src/images/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/icon16.png -------------------------------------------------------------------------------- /src/images/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/icon48.png -------------------------------------------------------------------------------- /src/images/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/icon64.png -------------------------------------------------------------------------------- /src/images/action19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/action19.png -------------------------------------------------------------------------------- /src/images/action38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/action38.png -------------------------------------------------------------------------------- /src/images/store_icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/images/store_icon128.png -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-Bold.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-Black.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-Italic.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-Light.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-Regular.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-Semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-Semibold.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-BlackItalic.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-BoldItalic.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-ExtraLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-ExtraLight.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-LightItalic.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-SemiboldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-SemiboldItalic.ttf -------------------------------------------------------------------------------- /src/font/SourceSansPro/SourceSansPro-ExtraLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pwnall/dropship-chrome/HEAD/src/font/SourceSansPro/SourceSansPro-ExtraLightItalic.ttf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vim. 2 | *.swp 3 | 4 | # OSX 5 | .DS_Store 6 | 7 | # Npm modules. 8 | node_modules 9 | 10 | # Vendored libraries. 11 | vendor 12 | 13 | # Build output. 14 | build 15 | release 16 | dropship-chrome.zip 17 | -------------------------------------------------------------------------------- /src/images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/html/chrome_oauth_receiver.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 |

Dropbox sign-in successful

10 | 11 |

Please close this window.

12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dropship-chrome", 3 | "version": "0.1.9", 4 | "description": "Chrome extension for downloading files straight to Dropbox", 5 | "keywords": ["dropbox", "download"], 6 | "homepage": "http://github.com/pwnall/dropship-chrome", 7 | "author": "Victor Costan (http://www.costan.us)", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/pwnall/dropship-chrome.git" 12 | }, 13 | "engines": { 14 | "chrome": ">= 33" 15 | }, 16 | "devDependencies": { 17 | "async": ">= 0.9.0", 18 | "codo": ">= 2.0.9", 19 | "coffee-script": ">= 1.8.0", 20 | "fs-extra": ">= 0.6.1", 21 | "glob": ">= 4.3.1", 22 | "less": ">= 2.1.1", 23 | "remove": ">= 0.1.5", 24 | "uglify-js": ">= 2.4.15", 25 | "watch": ">= 0.8.0" 26 | }, 27 | "directories": { 28 | "doc": "doc", 29 | "src": "src", 30 | "test": "test" 31 | }, 32 | "scripts": { 33 | "test": "cake test" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Victor Costan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC//8zp1SEjDzv5kjK1Gs7Ld5VDldSiL17aAUa5x6vKWdagoulAI3OwVLa3oiAqfjKivhE7b/A2NX4WEeVV6x+c14WP41k4+yr46Wuk7UgDhNi6fCS+Th7Kt1icuDlKE9QinoDaxpnkF68dvWUySRR79XwXQxzHfxePXDrnPGLvnQIDAQAB", 3 | "name": "Download to Dropbox", 4 | "version": "0.1.9", 5 | "manifest_version": 2, 6 | "description": "Conveniently download straight into your Dropbox account", 7 | "icons": { 8 | "16": "images/icon16.png", 9 | "48": "images/icon48.png", 10 | "128": "images/icon128.png" 11 | }, 12 | "browser_action": { 13 | "default_icon": { 14 | "19": "images/action19.png", 15 | "38": "images/action38.png" 16 | }, 17 | "default_title": "Starting..." 18 | }, 19 | "minimum_chrome_version": "24", 20 | "permissions": [ 21 | "", 22 | 23 | "chrome://favicon/", 24 | "contextMenus", 25 | "notifications", 26 | "storage", 27 | "unlimitedStorage" 28 | ], 29 | "optional_permissions": [ 30 | ], 31 | "background": { 32 | "scripts": [ 33 | "vendor/js/dropbox.js", 34 | "vendor/js/humanize.js", 35 | "vendor/js/uri.js", 36 | "js/dropbox_chrome.js", 37 | "js/download_controller.js", 38 | "js/dropship_file.js", 39 | "js/dropship_list.js", 40 | "js/options.js", 41 | "js/upload_controller.js", 42 | "js/event_page.js" 43 | ], 44 | "persistent": false 45 | }, 46 | "options_page": "html/options.html", 47 | "web_accessible_resources": [ 48 | "html/chrome_oauth_receiver.html", 49 | "images/icon16.png", 50 | "images/icon48.png", 51 | "images/icon64.png", 52 | "images/icon128.png" 53 | ], 54 | "incognito": "split" 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sample dropbox.js Chrome Extension 2 | 3 | This is a demonstration of using dropbox.js, the client library for the Dropbox 4 | API, inside a Google Chrome browser extension. 5 | 6 | Try out the extension by installing it from the 7 | [Google Chrome Store](https://chrome.google.com/webstore/detail/download-to-dropbox/mklccdhnpppcmbpbkaanmamjfmmefbnp). 8 | 9 | ## Development 10 | 11 | ### Dev Environment Setup 12 | 13 | Install [node.js](http://nodejs.org/#download) to get `npm` (the node 14 | package manager), then use it to install the libraries required by the build 15 | process. 16 | 17 | ```bash 18 | git clone https://github.com/pwnall/dropship-chrome.git 19 | cd dropship-chrome 20 | npm install 21 | ``` 22 | 23 | ### Build 24 | 25 | Run `cake build` and ignore any deprecation warnings that might come up. 26 | 27 | 28 | ```bash 29 | cake build 30 | ``` 31 | 32 | ### Install 33 | 34 | Follow the steps below to install the development version of the extension. You 35 | only need to do this once. 36 | 37 | 1. Type `chrome://extensions` in Chrome's address bar and press _Enter_. 38 | 1. Check the `Developer mode` checkbox. 39 | 1. Press the `Load unpacked extension...` button. 40 | 1. Navigate to the `build/` directory inside the extension and select it. 41 | 42 | Once the extension is installed, follow the steps below to reload it after a 43 | rebuild. 44 | 45 | 1. Right-click the Dropbox icon. 46 | 1. Select `Manage Extensions...` from the pop-up menu. 47 | 1. Click the `Reload` link under the Dropbox extension. 48 | 49 | ### Test 50 | 51 | This extension uses manual testing for now. 52 | 53 | 54 | ## Copyright and License 55 | 56 | The extension is Copyright (c) 2012 Victor Costan, and distributed under the 57 | MIT license. 58 | -------------------------------------------------------------------------------- /src/less/_fonts.less: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Source Sans Pro'; 3 | font-style: normal; 4 | font-weight: 200; 5 | src: url('/font/SourceSansPro/SourceSansPro-ExtraLight.ttf') format('truetype'); 6 | } 7 | @font-face { 8 | font-family: 'Source Sans Pro'; 9 | font-style: italic; 10 | font-weight: 200; 11 | src: url('/font/SourceSansPro/SourceSansPro-ExtraLightItalic.ttf') format('truetype'); 12 | } 13 | @font-face { 14 | font-family: 'Source Sans Pro'; 15 | font-style: normal; 16 | font-weight: 300; 17 | src: url('/font/SourceSansPro/SourceSansPro-Light.ttf') format('truetype'); 18 | } 19 | @font-face { 20 | font-family: 'Source Sans Pro'; 21 | font-style: italic; 22 | font-weight: 300; 23 | src: url('/font/SourceSansPro/SourceSansPro-LightItalic.ttf') format('truetype'); 24 | } 25 | 26 | @font-face { 27 | font-family: 'Source Sans Pro'; 28 | font-style: normal; 29 | font-weight: 400; 30 | src: url('/font/SourceSansPro/SourceSansPro-Regular.ttf') format('truetype'); 31 | } 32 | @font-face { 33 | font-family: 'Source Sans Pro'; 34 | font-style: italic; 35 | font-weight: 400; 36 | src: url('/font/SourceSansPro/SourceSansPro-Italic.ttf') format('truetype'); 37 | } 38 | 39 | @font-face { 40 | font-family: 'Source Sans Pro'; 41 | font-style: normal; 42 | font-weight: 600; 43 | src: url('/font/SourceSansPro/SourceSansPro-Semibold.ttf') format('truetype'); 44 | } 45 | @font-face { 46 | font-family: 'Source Sans Pro'; 47 | font-style: italic; 48 | font-weight: 600; 49 | src: url('/font/SourceSansPro/SourceSansPro-SemiboldItalic.ttf') format('truetype'); 50 | } 51 | @font-face { 52 | font-family: 'Source Sans Pro'; 53 | font-style: normal; 54 | font-weight: 700; 55 | src: url('/font/SourceSansPro/SourceSansPro-Bold.ttf') format('truetype'); 56 | } 57 | @font-face { 58 | font-family: 'Source Sans Pro'; 59 | font-style: italic; 60 | font-weight: 700; 61 | src: url('/font/SourceSansPro/SourceSansPro-BoldItalic.ttf') format('truetype'); 62 | } 63 | @font-face { 64 | font-family: 'Source Sans Pro'; 65 | font-style: normal; 66 | font-weight: 800; 67 | src: url('/font/SourceSansPro/SourceSansPro-Black.ttf') format('truetype'); 68 | } 69 | @font-face { 70 | font-family: 'Source Sans Pro'; 71 | font-style: italic; 72 | font-weight: 800; 73 | src: url('/font/SourceSansPro/SourceSansPro-BlackItalic.ttf') format('truetype'); 74 | } 75 | -------------------------------------------------------------------------------- /src/less/options.less: -------------------------------------------------------------------------------- 1 | @import '../../vendor/less/font_awesome/font-awesome.less'; 2 | @fa-font-path: "/vendor/font"; 3 | 4 | @import '_fonts.less'; 5 | 6 | html, body { 7 | margin: 0; 8 | padding: 0; 9 | font-family: "Source Sans Pro"; 10 | font-size: 16px; 11 | font-weight: 400; 12 | line-height: 1.25; 13 | 14 | vertical-align: baseline; 15 | width: 100%; 16 | } 17 | 18 | .hidden { 19 | display: none !important; 20 | } 21 | 22 | nav { 23 | display: table-cell; 24 | -webkit-user-select: none; 25 | user-select: none; 26 | cursor: default; 27 | } 28 | section#page-container { 29 | display: table-cell; 30 | margin: 0; 31 | padding: 0 0 0 3em; 32 | } 33 | 34 | 35 | h1 { 36 | font-weight: 300; 37 | color: hsl(210deg, 60%, 50%); 38 | font-size: 20px; 39 | line-height: 1.75; 40 | 41 | padding: 0 0 0 16px; 42 | } 43 | #nav-list { 44 | list-style: none; 45 | margin: 0; 46 | padding: 0; 47 | 48 | font-size: 16px; 49 | font-weight: 300; 50 | line-height: 2; 51 | color: hsl(0deg, 0%, 33%); 52 | } 53 | .nav-list-item { 54 | margin: 0; 55 | padding: 0; 56 | 57 | &:hover { 58 | background-color: hsl(208deg, 30%, 95%); 59 | color: hsl(0, 0%, 0%); 60 | } 61 | &.current { 62 | font-weight: 400; 63 | color: hsl(0, 0%, 0%); 64 | } 65 | 66 | a, a:hover, a:focus { 67 | margin: 0; 68 | padding: 0 0 0 16px; 69 | display: block; 70 | 71 | color: inherit; 72 | text-decoration: none; 73 | } 74 | } 75 | 76 | 77 | h2 { 78 | font-weight: 600; 79 | font-size: 20px; 80 | border-bottom: 1px solid hsl(0deg, 0%, 85%); 81 | line-height: 1.75; 82 | } 83 | 84 | #download-folder-sample { 85 | display: block; 86 | 87 | margin: 0.5em 0; 88 | padding: 0.1em 0.33em; 89 | border: 1px solid hsl(0deg, 0%, 85%); 90 | border-radius: 4px; 91 | box-shadow: inset rgba(0, 0, 0, 0.1) 1px 1px 2px; 92 | 93 | font-weight: 300; 94 | font-size: 18px; 95 | line-height: 1.5; 96 | } 97 | 98 | #dropbox-info { 99 | #dropbox-signout i { 100 | color: hsl(0deg, 75%, 35%); 101 | } 102 | } 103 | #dropbox-no-info { 104 | #dropbox-no-identity { 105 | font-size: 14px; 106 | font-weight: 300px; 107 | font-style: italic; 108 | } 109 | #dropbox-signin i { 110 | color: hsl(120deg, 75%, 35%); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/html/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Download to Dropbox 5 | 6 | 7 | 8 | 9 | 10 | 27 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/html/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Download to Dropbox 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 41 | 42 |
    43 |
44 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/coffee/dropbox_chrome.coffee: -------------------------------------------------------------------------------- 1 | # Common functionality for Chrome apps / extensions using dropbox.js. 2 | class Dropbox.Chrome 3 | # @param {Object} options to be passed to the Dropbox API client; the object 4 | # should have the property 'key' 5 | constructor: (@clientOptions) -> 6 | @_client = null 7 | @_clientCallbacks = null 8 | @_userInfo = null 9 | @_userInfoCallbacks = null 10 | @onClient = new Dropbox.Util.EventSource 11 | 12 | # @property {Dropbox.Util.EventSource} triggered when a new 13 | # Dropbox.Client instance is created; can be used to attach listeners to 14 | # the client 15 | onClient: null 16 | 17 | # Produces a properly set up Dropbox API client. 18 | # 19 | # @param {function(Dropbox.Client)} callback called with a properly set up 20 | # Dropbox.Client instance 21 | # @return {Dropbox.Chrome} this 22 | client: (callback) -> 23 | if @_client 24 | callback @_client 25 | return @ 26 | 27 | if @_clientCallbacks 28 | @_clientCallbacks.push callback 29 | return @ 30 | @_clientCallbacks = [callback] 31 | 32 | client = new Dropbox.Client @clientOptions 33 | client.authDriver new Dropbox.AuthDriver.ChromeExtension( 34 | receiverPath: 'html/chrome_oauth_receiver.html') 35 | # Try loading cached credentials, if they are available. 36 | client.authenticate interactive: false, (error) => 37 | # Drop the cached user info when the credentials become invalid. 38 | client.onAuthStepChange.addListener => 39 | @_userInfo = null 40 | 41 | @onClient.dispatch client 42 | @_client = client 43 | callbacks = @_clientCallbacks 44 | @_clientCallbacks = null 45 | callback(@_client) for callback in callbacks 46 | @ 47 | 48 | # Returns a (potentially cached) version of the Dropbox user's information. 49 | # 50 | # @param {function(Dropbox.AccountInfo)} callback called when the 51 | # Dropbox.AccountInfo becomes available 52 | # @return {Dropbox.Chrome} this 53 | userInfo: (callback) -> 54 | if @_userInfo 55 | callback @_userInfo 56 | return @ 57 | 58 | if @_userInfoCallbacks 59 | @_userInfoCallbacks.push callback 60 | return @ 61 | @_userInfoCallbacks = [callback] 62 | 63 | dispatchUserInfo = => 64 | callbacks = @_userInfoCallbacks 65 | @_userInfoCallbacks = null 66 | callback(@_userInfo) for callback in callbacks 67 | 68 | chrome.storage.local.get 'dropbox_js_userinfo', (items) => 69 | if items and items.dropbox_js_userinfo 70 | try 71 | @_userInfo = Dropbox.AccountInfo.parse items.dropbox_js_userinfo 72 | return dispatchUserInfo() 73 | catch parseError 74 | @_userInfo = null 75 | # There was a parsing error. Let the control flow fall. 76 | 77 | @client (client) => 78 | unless client.isAuthenticated() 79 | @_userInfo = {} 80 | return dispatchUserInfo() 81 | client.getUserInfo (error, userInfo) => 82 | if error 83 | @_userInfo = {} 84 | return dispatchUserInfo() 85 | chrome.storage.local.set dropbox_js_userinfo: userInfo.json(), => 86 | @_userInfo = userInfo 87 | dispatchUserInfo() 88 | @ 89 | 90 | # Signs the user out of Dropbox and clears their cached information. 91 | # 92 | # @param {function()} callback called when the user's token is invalidated 93 | # and the cached information is removed 94 | # @return {Dropbox.Chrome} this 95 | signOut: (callback) -> 96 | @client (client) => 97 | unless client.isAuthenticated() 98 | return callback() 99 | 100 | client.signOut => 101 | @_userInfo = null 102 | chrome.storage.local.remove 'dropbox_js_userinfo', => 103 | callback() 104 | -------------------------------------------------------------------------------- /src/coffee/options.coffee: -------------------------------------------------------------------------------- 1 | # Manages the extension settings, which are synced across Chrome instances. 2 | class Options 3 | constructor: -> 4 | @_items = null 5 | @_itemsCallbacks = null 6 | @loadedAt = null 7 | @onChange = new Dropbox.Util.EventSource 8 | chrome.storage.onChanged.addListener (changes, areaName) => 9 | @onStorageChanges changes, areaName 10 | 11 | # @property {Dropbox.Util.EventSource>} fires 12 | # non-cancelable events when the extension settings change 13 | onChange: null 14 | 15 | # Reads the settings from Chrome's synchronized storage. 16 | # 17 | # @param {function(Object)} callback called when the settings 18 | # are available 19 | # @return {Options} this 20 | items: (callback) -> 21 | if @_items 22 | if Date.now() - @loadedAt < @cacheDurationMs 23 | callback @_items 24 | return @ 25 | else 26 | @_items = null 27 | 28 | if @_itemsCallbacks 29 | @_itemsCallbacks.push callback 30 | return @ 31 | 32 | @_itemsCallbacks = [callback] 33 | chrome.storage.sync.get 'settings', (storage) => 34 | items = storage.settings || {} 35 | @addDefaults items 36 | @_items = items 37 | @loadedAt = Date.now() 38 | callbacks = @_itemsCallbacks 39 | @_itemsCallbacks = null 40 | for callback in callbacks 41 | callback @_items 42 | @ 43 | 44 | # Writes the settings to Chrome's synchronized storage. 45 | # 46 | # @param {Object} items the settings to be written; this 47 | # should be the object passed to an Options#items() callback, optionally 48 | # with some properties modified 49 | # @return {Options} this 50 | setItems: (items, callback) -> 51 | chrome.storage.sync.set settings: items, => 52 | @_items = items 53 | @loadedAt = Date.now() 54 | callback() if callback 55 | @ 56 | 57 | # Computes the location of a downloaded item for the example in options. 58 | # 59 | # @param {Object} items object passed to an Options#items() 60 | # callback 61 | # @return {String} the download location, relative to the user's Dropbox 62 | sampleDownloadFolder: (items) -> 63 | folder = '/Apps/Chrome Downloads' 64 | if items.downloadDateFolder 65 | folder += '/' + humanize.date('Y-m-d', new Date()) 66 | if items.downloadSiteFolder 67 | folder += '/en.wikipedia.org' 68 | folder 69 | 70 | # Computes the path where a file will be uploaded in the user's Dropbox. 71 | # 72 | # @param {DropshipFile} file descriptor for the file to be uploaded 73 | # @param {Object items object passed to an Options#items() 74 | # callback 75 | # @return {String} the path of the file, relative to the extension's folder 76 | # in the user's Dropbox 77 | downloadPath: (file, items) -> 78 | uploadPath = file.uploadBasename() 79 | if items.downloadDateFolder 80 | uploadPath = humanize.date('Y-m-d', new Date(file.startedAt)) + '/' + 81 | uploadPath 82 | if items.downloadSiteFolder 83 | uploadPath = new URI(file.url).hostname() + '/' + uploadPath 84 | uploadPath 85 | 86 | # Called when the contents of Chrome storage areas change. 87 | onStorageChanges: (changes, areaName) -> 88 | return unless areaName is 'sync' 89 | return unless 'settings' of changes 90 | 91 | items = changes['settings'].newValue or {} 92 | @addDefaults items 93 | @_items = items 94 | @loadedAt = Date.now() 95 | @onChange.dispatch @_items 96 | 97 | # Fills in default values for missing settings. 98 | # 99 | # @private 100 | # Used internally by items(). Call items() instead of using this directly. 101 | # 102 | # @params {Object} an object passed to an Options#items() 103 | # callback 104 | # @return 105 | addDefaults: (items) -> 106 | unless 'downloadSiteFolder' of items 107 | items.downloadSiteFolder = false 108 | unless 'downloadDateFolder' of items 109 | items.downloadDateFolder = false 110 | unless 'autoDownload' of items 111 | items.autoDownload = false 112 | unless 'autoDownloadExts' of items 113 | items.autoDownloadExts = [] 114 | unless 'autoDownloadMimes' of items 115 | items.autoDownloadMimes = [] 116 | 117 | items 118 | 119 | # Number of milliseconds during which settings are cached. 120 | cacheDurationMs: 10 * 60 121 | 122 | window.Options = Options 123 | -------------------------------------------------------------------------------- /src/coffee/options_view.coffee: -------------------------------------------------------------------------------- 1 | # Renders the current options. 2 | class OptionsView 3 | constructor: (@root) -> 4 | @$root = $ @root 5 | @$userInfo = $ '#dropbox-info', @$root 6 | @$userName = $ '#dropbox-name', @$userInfo 7 | @$userEmail = $ '#dropbox-email', @$userInfo 8 | @$signoutButton = $ '#dropbox-signout', @$userInfo 9 | @$signoutButton.on 'click', (event) => @onSignoutClick event 10 | @$userNoInfo = $ '#dropbox-no-info', @$root 11 | @$signinButton = $ '#dropbox-signin', @$userNoInfo 12 | @$signinButton.on 'click', (event) => @onSigninClick event 13 | @$navItems = $ '#nav-list .nav-list-item', @$root 14 | @$navLinks = $ '#nav-list .nav-list-item a', @$root 15 | @$pageContainer = $ '#page-container', @$root 16 | @$pages = $ '#page-container article', @$root 17 | 18 | @$downloadSiteFolder = $ '#download-site-folder', @$root 19 | @$downloadSiteFolder.on 'change', => @onChange() 20 | @$downloadDateFolder = $ '#download-date-folder', @$root 21 | @$downloadDateFolder.on 'change', => @onChange() 22 | @$downloadFolderSample = $ '#download-folder-sample', @$root 23 | 24 | @updateData => 25 | @updateVisiblePage() 26 | @$pageContainer.removeClass 'hidden' 27 | window.addEventListener 'hashchange', (event) => @updateVisiblePage() 28 | 29 | chrome.extension.onMessage.addListener (message) => @onMessage message 30 | 31 | @reloadUserInfo() 32 | 33 | # Updates the DOM with the current settings. 34 | # 35 | # @param {function()} callback called when the DOM reflects the current 36 | # settings 37 | # @return {OptionsView} this 38 | updateData: (callback) -> 39 | chrome.runtime.getBackgroundPage (eventPage) => 40 | options = eventPage.controller.options 41 | options.items (items) => 42 | @$downloadSiteFolder.prop 'checked', items.downloadSiteFolder 43 | @$downloadDateFolder.prop 'checked', items.downloadDateFolder 44 | @$downloadFolderSample.text options.sampleDownloadFolder(items) 45 | 46 | callback() 47 | @ 48 | 49 | # Called when one of the settings on the page changes. 50 | onChange: -> 51 | chrome.runtime.getBackgroundPage (eventPage) => 52 | options = eventPage.controller.options 53 | options.items (items) => 54 | items.downloadSiteFolder = @$downloadSiteFolder.prop 'checked' 55 | items.downloadDateFolder = @$downloadDateFolder.prop 'checked' 56 | @$downloadFolderSample.text options.sampleDownloadFolder(items) 57 | 58 | options.setItems items, -> null 59 | @ 60 | 61 | # Changes the markup classes to show the page identified by the hashtag. 62 | # 63 | # @return {OptionsView} this 64 | updateVisiblePage: -> 65 | pageId = window.location.hash.substring(1) or @defaultPage 66 | @$pages.each (index, page) -> 67 | $page = $ page 68 | $page.toggleClass 'hidden', $page.attr('id') isnt pageId 69 | pageHash = '#' + pageId 70 | @$navItems.each (index, navItem) => 71 | $navItem = $ navItem 72 | $navLink = $ @$navLinks[index] 73 | $navItem.toggleClass 'current', $navLink.attr('href') is pageHash 74 | @ 75 | 76 | # Called when the user clicks on the 'Sign out' button. 77 | onSignoutClick: (event) -> 78 | event.preventDefault() 79 | chrome.runtime.getBackgroundPage (eventPage) -> 80 | eventPage.controller.signOut => null 81 | false 82 | 83 | # Called when the user clicks on the 'Sign in' button. 84 | onSigninClick: (event) -> 85 | chrome.runtime.getBackgroundPage (eventPage) -> 86 | eventPage.controller.signIn => null 87 | false 88 | 89 | # Updates the Dropbox user information in the view. 90 | reloadUserInfo: -> 91 | chrome.runtime.getBackgroundPage (eventPage) => 92 | eventPage.controller.dropboxChrome.userInfo (userInfo) => 93 | if userInfo.name 94 | @$userNoInfo.addClass 'hidden' 95 | @$userInfo.removeClass 'hidden' 96 | @$userName.text userInfo.name 97 | @$userEmail.text userInfo.email 98 | else 99 | @$userNoInfo.removeClass 'hidden' 100 | @$userInfo.addClass 'hidden' 101 | @ 102 | 103 | # Called when a Chrome extension internal message is received. 104 | onMessage: (message) -> 105 | switch message.notice 106 | when 'dropbox_auth' 107 | @reloadUserInfo() 108 | 109 | # The page that is shown when the options view is shown. 110 | defaultPage: 'download-flags' 111 | 112 | $ -> 113 | window.view = new OptionsView document.body 114 | 115 | -------------------------------------------------------------------------------- /src/font/SourceSansPro/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. 2 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 3 | This license is copied below, and is also available with a FAQ at: 4 | http://scripts.sil.org/OFL 5 | 6 | 7 | ----------------------------------------------------------- 8 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 9 | ----------------------------------------------------------- 10 | 11 | PREAMBLE 12 | The goals of the Open Font License (OFL) are to stimulate worldwide 13 | development of collaborative font projects, to support the font creation 14 | efforts of academic and linguistic communities, and to provide a free and 15 | open framework in which fonts may be shared and improved in partnership 16 | with others. 17 | 18 | The OFL allows the licensed fonts to be used, studied, modified and 19 | redistributed freely as long as they are not sold by themselves. The 20 | fonts, including any derivative works, can be bundled, embedded, 21 | redistributed and/or sold with any software provided that any reserved 22 | names are not used by derivative works. The fonts and derivatives, 23 | however, cannot be released under any other type of license. The 24 | requirement for fonts to remain under this license does not apply 25 | to any document created using the fonts or their derivatives. 26 | 27 | DEFINITIONS 28 | "Font Software" refers to the set of files released by the Copyright 29 | Holder(s) under this license and clearly marked as such. This may 30 | include source files, build scripts and documentation. 31 | 32 | "Reserved Font Name" refers to any names specified as such after the 33 | copyright statement(s). 34 | 35 | "Original Version" refers to the collection of Font Software components as 36 | distributed by the Copyright Holder(s). 37 | 38 | "Modified Version" refers to any derivative made by adding to, deleting, 39 | or substituting -- in part or in whole -- any of the components of the 40 | Original Version, by changing formats or by porting the Font Software to a 41 | new environment. 42 | 43 | "Author" refers to any designer, engineer, programmer, technical 44 | writer or other person who contributed to the Font Software. 45 | 46 | PERMISSION & CONDITIONS 47 | Permission is hereby granted, free of charge, to any person obtaining 48 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 49 | redistribute, and sell modified and unmodified copies of the Font 50 | Software, subject to the following conditions: 51 | 52 | 1) Neither the Font Software nor any of its individual components, 53 | in Original or Modified Versions, may be sold by itself. 54 | 55 | 2) Original or Modified Versions of the Font Software may be bundled, 56 | redistributed and/or sold with any software, provided that each copy 57 | contains the above copyright notice and this license. These can be 58 | included either as stand-alone text files, human-readable headers or 59 | in the appropriate machine-readable metadata fields within text or 60 | binary files as long as those fields can be easily viewed by the user. 61 | 62 | 3) No Modified Version of the Font Software may use the Reserved Font 63 | Name(s) unless explicit written permission is granted by the corresponding 64 | Copyright Holder. This restriction only applies to the primary font name as 65 | presented to the users. 66 | 67 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 68 | Software shall not be used to promote, endorse or advertise any 69 | Modified Version, except to acknowledge the contribution(s) of the 70 | Copyright Holder(s) and the Author(s) or with their explicit written 71 | permission. 72 | 73 | 5) The Font Software, modified or unmodified, in part or in whole, 74 | must be distributed entirely under this license, and must not be 75 | distributed under any other license. The requirement for fonts to 76 | remain under this license does not apply to any document created 77 | using the Font Software. 78 | 79 | TERMINATION 80 | This license becomes null and void if any of the above conditions are 81 | not met. 82 | 83 | DISCLAIMER 84 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 85 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 86 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 87 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 88 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 89 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 90 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 91 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 92 | OTHER DEALINGS IN THE FONT SOFTWARE. 93 | -------------------------------------------------------------------------------- /src/less/popup.less: -------------------------------------------------------------------------------- 1 | @import '../../vendor/less/font_awesome/font-awesome.less'; 2 | @fa-font-path: "/vendor/font"; 3 | 4 | @import '_fonts.less'; 5 | 6 | html, body { 7 | margin: 0; 8 | padding: 0; 9 | font-family: "Source Sans Pro"; 10 | font-size: 16px; 11 | line-height: 1.25; 12 | } 13 | 14 | .hidden { 15 | display: none !important; 16 | } 17 | 18 | #header { 19 | margin: 0; 20 | padding: 0.2em 0.5em; 21 | box-sizing: border-box; 22 | width: 100%; 23 | display: table; 24 | background-color: hsl(0deg, 0%, 96%); 25 | 26 | #header-bar { 27 | display: table-row; 28 | 29 | #dropbox-signout-form { 30 | display: table-cell; 31 | text-align: left; 32 | font-size: 20px; 33 | white-space: nowrap; 34 | 35 | #options-button { 36 | color: hsl(0deg, 0%, 35%); 37 | } 38 | #cleanup-files-button { 39 | color: hsl(0deg, 0%, 35%); 40 | } 41 | } 42 | 43 | #dropbox-info, #dropbox-no-info { 44 | display: table-cell; 45 | text-align: center; 46 | 47 | margin: 0; 48 | padding: 0; 49 | min-width: 30em; 50 | 51 | white-space: nowrap; 52 | } 53 | #dropbox-info { 54 | font-size: 14px; 55 | font-weight: 400; 56 | } 57 | #dropbox-no-info { 58 | font-size: 14px; 59 | font-weight: 300; 60 | font-style: italic; 61 | } 62 | 63 | #window-actions { 64 | display: table-cell; 65 | text-align: right; 66 | font-size: 20px; 67 | white-space: nowrap; 68 | 69 | #maximize-window-button { 70 | color: hsl(120deg, 90%, 30%); 71 | } 72 | #close-window-button { 73 | color: hsl(0deg, 100%, 40%); 74 | } 75 | } 76 | 77 | button { 78 | display: inline-block; 79 | margin: 0; 80 | padding: 0; 81 | border: none; 82 | outline: none; 83 | background: transparent; 84 | 85 | font: inherit; 86 | 87 | text-shadow: rgba(0, 0, 0, 0.20) 1px 1px 1px; 88 | &:hover { 89 | text-shadow: rgba(0, 0, 0, 0.5) 1px 1px 2px; 90 | } 91 | &:active { 92 | text-shadow: none; 93 | } 94 | i:before { 95 | width: 1.1em; 96 | } 97 | } 98 | } 99 | } 100 | 101 | 102 | #file-list { 103 | list-style: none; 104 | margin: 0; 105 | padding: 0; 106 | display: block; 107 | border-top: 1px solid hsl(0deg, 0, 92%); 108 | 109 | .file-item { 110 | margin: 0; 111 | padding: 0.33em 0.5em 0.33em 0.1em; 112 | 113 | border-top: 1px solid hsl(0deg, 0, 92%); 114 | &:first-child { 115 | border-top: none; 116 | } 117 | } 118 | 119 | .file-item-main { 120 | display: -webkit-flex; 121 | display: flex; 122 | -webkit-flex-flow: row; 123 | flex-flow: row; 124 | -webkit-justify-content: space-between; 125 | justify-content: space-between; 126 | } 127 | 128 | .file-item-status { 129 | -webkit-flex: 0 0 auto; 130 | width: 1.5em; 131 | font-size: 32px; 132 | text-align: left; 133 | 134 | i { 135 | &.file-status-done { 136 | color: hsl(120deg, 50%, 35%); 137 | } 138 | &.file-status-canceled { 139 | color: hsl(0deg, 0%, 45%); 140 | } 141 | &.file-status-error { 142 | color: hsl(0deg, 50%, 35%); 143 | } 144 | &.file-status-inprogress { 145 | color: hsl(200deg, 50%, 35%); 146 | } 147 | } 148 | } 149 | 150 | .file-item-data { 151 | -webkit-flex: 1 1 auto; 152 | 153 | .file-item-metadata { 154 | display: block; 155 | width: 100%; 156 | max-width: 40em; 157 | 158 | white-space: nowrap; 159 | font-size: 18px; 160 | 161 | .file-name { 162 | display: inline-block; 163 | text-align: left; 164 | 165 | overflow: hidden; 166 | text-overflow: ellipsis; 167 | white-space: nowrap; 168 | } 169 | .file-size { 170 | display: inline-block; 171 | margin-left: 1em; 172 | text-align: right; 173 | white-space: nowrap; 174 | font-weight: 300; 175 | color: hsl(0deg, 0%, 50%); 176 | } 177 | } 178 | .file-item-source { 179 | display: block; 180 | width: 100%; 181 | max-width: 40em; 182 | 183 | font-size: 14px; 184 | font-weight: 300; 185 | padding-top: 0.15em; 186 | 187 | overflow: hidden; 188 | text-overflow: ellipsis; 189 | white-space: nowrap; 190 | 191 | .file-item-link { 192 | text-decoration: none; 193 | color: hsl(218deg, 85%, 43%); 194 | &:visited, &:active { 195 | color: hsl(218deg, 85%, 43%); 196 | } 197 | } 198 | } 199 | 200 | .file-item-error { 201 | display: block; 202 | width: 100%; 203 | max-width: 40em; 204 | color: hsl(0deg, 75%, 35%); 205 | overflow: hidden; 206 | text-overflow: ellipsis; 207 | } 208 | } 209 | 210 | .file-actions { 211 | -webkit-flex: 0 0 auto; 212 | display: block; 213 | width: 1.2em; 214 | text-align: right; 215 | 216 | font-size: 18px; 217 | 218 | .file-item-hide { 219 | color: hsl(0deg, 0%, 35%); 220 | } 221 | .file-item-retry { 222 | color: hsl(120deg, 75%, 25%); 223 | } 224 | .file-item-cancel { 225 | color: hsl(0deg, 75%, 35%); 226 | } 227 | button { 228 | display: inline-block; 229 | margin: 0; 230 | padding: 0; 231 | outline: none; 232 | 233 | border: none; 234 | background: transparent; 235 | 236 | font: inherit; 237 | line-height: 1.25; 238 | 239 | text-shadow: rgba(0, 0, 0, 0.20) 1px 1px 1px; 240 | &:hover { 241 | text-shadow: rgba(0, 0, 0, 0.6) 1px 1px 3px; 242 | } 243 | &:active { 244 | text-shadow: none; 245 | } 246 | } 247 | } 248 | 249 | .file-progress-wrapper { 250 | display: block; 251 | list-style: none; 252 | margin: 0; 253 | padding: 0.3em 0 0 0; 254 | font-size: 18px; 255 | vertical-align: middle; 256 | 257 | .file-item-progress { 258 | display: inline-block; 259 | margin: 0; 260 | width: ~"-webkit-calc(100% - 1.7em)"; 261 | width: ~"calc(100% - 1.7em)"; 262 | } 263 | } 264 | } 265 | 266 | -------------------------------------------------------------------------------- /src/coffee/download_controller.coffee: -------------------------------------------------------------------------------- 1 | # Manages the process of downloading files from their origin site. 2 | class DownloadController 3 | constructor: -> 4 | @fileCount = 0 5 | @files = {} 6 | @xhrs = {} 7 | @onPermissionDenied = new Dropbox.Util.EventSource 8 | @onStateChange = new Dropbox.Util.EventSource 9 | 10 | # @property {Dropbox.Util.EventSource} non-cancelable event fired when 11 | # the user denies the temporary permissions needed to download a file; 12 | # listeners should inform the user that their download was canceled 13 | onPermissionDenied: null 14 | 15 | # @property {Dropbox.Util.EventSource} non-cancelable event 16 | # fired when a file download makes progress, completes, or stops due to an 17 | # error; this event does not fire when a download is canceled 18 | onStateChange: null 19 | 20 | # Adds a file to the list of files to be downloaded. 21 | # 22 | # @param {DropshipFile} file descriptor for the file to be downloaded 23 | # @param {function()} callback called when the file is successfully added for 24 | # download; not called if the download is aborted due to permission issues 25 | # @return {DownloadController} this 26 | addFile: (file, callback) -> 27 | if @files[file.uid] 28 | callback() 29 | return @ 30 | 31 | @getPermissions true, => 32 | @fileCount += 1 33 | @files[file.uid] = file 34 | 35 | # NOTE: using Dropbox.Util.Xhr for brevity; for robustness, 36 | # XMLHttpRequest should be used directly 37 | dbXhr = new Dropbox.Util.Xhr file.httpMethod, file.url 38 | dbXhr.setResponseType 'blob' 39 | for own name, value of file.headers 40 | continue if name.toLowerCase() of @restrictedHeaders 41 | dbXhr.setHeader name, value 42 | dbXhr.prepare() 43 | @xhrs[file.uid] = dbXhr.xhr 44 | dbXhr.xhr.addEventListener 'progress', (event) => 45 | @onXhrProgress file, event 46 | dbXhr.send (error, blob) => @onXhrResponse file, error, blob 47 | callback() 48 | 49 | # Cancels the download process for a file. 50 | # 51 | # @param {DropshipFile} file descriptor for the file whose download will be 52 | # canceled 53 | # @param {function()} callback called when the file download is canceled 54 | # @return {DownloadController} this 55 | cancelFile: (file, callback) -> 56 | # Ignore already canceled / completed downloads. 57 | unless @xhrs[file.uid] 58 | callback() 59 | return @ 60 | 61 | delete @xhrs[file.uid] # Avoid getting an error callback. 62 | try 63 | @xhrs[file.uid].abort() 64 | catch error 65 | # Ignore the XHR object complaining. 66 | 67 | @removeFile file, callback 68 | 69 | # Called when an XHR downloading a file completes. 70 | # 71 | # @param {DropshipFile} file the file being download 72 | # @param {?Dropbox.ApiError} error set if the XHR went wrong 73 | # @param {?Blob} blob the downloaded file 74 | onXhrResponse: (file, error, blob) -> 75 | # Ignore canceled downloads. 76 | return unless @xhrs[file.uid] 77 | 78 | if error 79 | file.setDownloadError error 80 | else if blob 81 | file.setContents blob 82 | 83 | @removeFile file, => 84 | @onStateChange.dispatch file 85 | 86 | # Called when an XHR downloading a file makes progress. 87 | onXhrProgress: (file, event) -> 88 | # Ignore canceled downloads. 89 | unless @xhrs[file.uid] 90 | event.target.abort() 91 | return 92 | 93 | downloadedBytes = event.loaded 94 | totalBytes = if event.lengthComputable then event.total else null 95 | file.setDownloadProgress downloadedBytes, totalBytes 96 | @onStateChange.dispatch file 97 | 98 | # Removes one of the files tracked for download. 99 | # 100 | # @private Called by cancelFile and onXhrResponse. 101 | # @return {UploadController} this 102 | removeFile: (file, callback) -> 103 | if @files[file.uid] 104 | delete @xhrs[file.uid] 105 | delete @files[file.uid] 106 | @fileCount -= 1 107 | 108 | @getPermissions false, callback 109 | 110 | # Manages temporary Chrome permissions. 111 | # 112 | # When the first file is queued for download, we temporarily request the 113 | # permission to access all the user's files (scary). When we have nothing 114 | # left to download, we drop the permission. 115 | # 116 | # @param {Boolean} addFile true if a download will be started, false if a 117 | # download just ended 118 | # @param {function()} callback called when the permissions are set correctly; 119 | # if the user denies some permissions, the callback is not called at all; 120 | # instead, 121 | # 122 | # @return {DownloadController} this 123 | getPermissions: (newDownload, callback) -> 124 | if newDownload 125 | chrome.permissions.contains origins: [''], (allowed) => 126 | if allowed 127 | return callback() 128 | chrome.permissions.request origins: [''], (granted) => 129 | if granted 130 | return callback() 131 | @onPermissionDenied.dispatch null 132 | else 133 | unless @fileCount is 0 134 | return callback() 135 | chrome.permissions.contains origins: [''], (allowed) -> 136 | unless allowed 137 | return callback() 138 | chrome.permissions.remove origins: [''], -> callback() 139 | @ 140 | 141 | # Headers that cannot be set via XHR. 142 | # 143 | # List lifted from the W3C spec: 144 | # http://www.w3.org/TR/XMLHttpRequest/#the-setrequestheader()-method 145 | restrictedHeaders: 146 | 'accept-charset': true 147 | 'accept-encoding': true 148 | 'access-control-request-headers': true 149 | 'access-control-request-method': true 150 | 'connection': true 151 | 'content-length': true 152 | 'cookie': true 153 | 'cookie2': true 154 | 'date': true 155 | 'dnt': true 156 | 'expect': true 157 | 'host': true 158 | 'keep-alive': true 159 | 'origin': true 160 | 'referer': true 161 | 'te': true 162 | 'trailer': true 163 | 'transfer-encoding': true 164 | 'upgrade': true 165 | 'user-agent': true 166 | 'via': true 167 | 168 | window.DownloadController = DownloadController 169 | -------------------------------------------------------------------------------- /src/coffee/upload_controller.coffee: -------------------------------------------------------------------------------- 1 | # Manages the process of uploading files to Dropbox. 2 | class UploadController 3 | # @param {Dropbox.Chrome} dropboxChrome Chrome extension-friendly wraper for 4 | # the Dropbox client to be used for uploading 5 | # @param {Options} options the extension's settings manager 6 | constructor: (@dropboxChrome, @options) -> 7 | @files = {} 8 | @xhrs = {} 9 | @onStateChange = new Dropbox.Util.EventSource 10 | 11 | # @property {Dropbox.Util.EventSource} non-cancelable event 12 | # fired when a file upload makes progress, completes, or stops due to an 13 | # error; this event does not fire when an upload is canceled 14 | onStateChange: null 15 | 16 | # Adds a file to the list of files to be uploaded. 17 | # 18 | # @param {DropshipFile} file descriptor for the file to be uploaded 19 | # @param {function()} callback called when the file is successfully added for 20 | # upload 21 | # @return {UploadController} this 22 | addFile: (file, callback) -> 23 | if @files[file.uid] 24 | callback() 25 | return @ 26 | @files[file.uid] = file 27 | 28 | if file.size < @atomicUploadCutoff 29 | @atomicUpload file, callback 30 | else 31 | @resumableUploadStep file, callback 32 | 33 | # Cancels a Dropbox file write. 34 | # 35 | # @param {DropshipFile} file descriptor for the file whose upload will be 36 | # canceled 37 | # @param {function()} callback called when the file upload is canceled 38 | # @return {UploadController} this 39 | cancelFile: (file, callback) -> 40 | # Ignore already canceled / completed uploads. 41 | unless @xhrs[file.uid] 42 | callback() 43 | return @ 44 | 45 | delete @xhrs[file.uid] # Avoid getting an error callback. 46 | try 47 | @xhrs[file.uid].abort() 48 | catch error 49 | # Ignore the XHR object complaining. 50 | 51 | @removeFile file, callback 52 | 53 | # One-step upload method that either works or fails. 54 | # 55 | # This is suitable for smaller files. It does not work at all for very large 56 | # (> 150MB) files, and has unreliable progress metering for medium (> 10MB) 57 | # files. 58 | # 59 | # @param {DropshipFile} file descriptor for the file to be uploaded 60 | # @param {function()} callback called when the file is successfully added for 61 | # upload 62 | # @return {UploadController} this 63 | atomicUpload: (file, callback) -> 64 | @dropboxChrome.client (client) => 65 | @options.items (items) => 66 | xhrListener = (dbXhr) => 67 | xhr = dbXhr.xhr 68 | xhr.upload.addEventListener 'progress', (event) => 69 | @onXhrUploadProgress file, xhr, event 70 | filePath = @options.downloadPath file, items 71 | client.onXhr.addListener xhrListener 72 | @xhrs[file.uid] = client.writeFile filePath, file.blob, 73 | noOverwrite: true, (error, stat) => @onDropboxWrite file, error, stat 74 | client.onXhr.removeListener xhrListener 75 | callback() 76 | @ 77 | 78 | # Called when a Dropbox write request completes. 79 | # 80 | # @param {DropshipFile} file the file being uploaded 81 | # @param {?Dropbox.ApiError} error set if the API call went wrong 82 | # @param {Dropbox.Stat} stat the file's metadata in Dropbox 83 | onDropboxWrite: (file, error, stat) -> 84 | # Ignore canceled uploads. 85 | return unless @xhrs[file.uid] 86 | 87 | if error 88 | file.setUploadError error 89 | else 90 | file.setDropboxStat stat 91 | 92 | @removeFile file, => 93 | @onStateChange.dispatch file 94 | 95 | # Multi-step upload method that can be resumed if one step fails. 96 | # 97 | # Performing one step is much slower than a simple upload for small files, so 98 | # this method is only suitable for large files, where the time for uploading 99 | # the data dwarves the step overhead. 100 | # 101 | # @param {DropshipFile} file descriptor for the file to be uploaded 102 | # @param {function()} callback called when the file is successfully added for 103 | # upload 104 | # @return {UploadController} this 105 | resumableUploadStep: (file, callback) -> 106 | @dropboxChrome.client (client) => 107 | xhrListener = (dbXhr) => 108 | xhr = dbXhr.xhr 109 | xhr.upload.addEventListener 'progress', (event) => 110 | @onXhrUploadProgress file, xhr, event 111 | client.onXhr.addListener xhrListener 112 | file.uploadCursor or= new Dropbox.Http.UploadCursor 113 | cursor = file.uploadCursor 114 | stepBlob = file.blob.slice cursor.offset, cursor.offset + @uploadStepSize 115 | @xhrs[file.uid] = client.resumableUploadStep stepBlob, cursor, 116 | (error, cursor) => @onDropboxWriteStep file, error, cursor 117 | client.onXhr.removeListener xhrListener 118 | callback() 119 | @ 120 | 121 | # Finishing step in the multi-step upload method. 122 | # 123 | # @param {DropshipFile} file descriptor for the file getting uploaded 124 | # @param {function()} callback called when the file is successfully added for 125 | # upload 126 | # @return {UploadController} this 127 | resumableUploadFinish: (file, callback) -> 128 | @dropboxChrome.client (client) => 129 | @options.items (items) => 130 | filePath = @options.downloadPath file, items 131 | @xhrs[file.uid] = client.resumableUploadFinish filePath, 132 | file.uploadCursor, noOverwrite: true, 133 | (error, stat) => @onDropboxWrite file, error, stat 134 | callback() 135 | @ 136 | 137 | # Called when a step in a multi-step Dropbox write request completes. 138 | # 139 | # @param {DropshipFile} file the file being uploaded 140 | # @param {?Dropbox.ApiError} error set if the API call went wrong 141 | # @param {Dropbox.Stat} stat the file's metadata in Dropbox 142 | onDropboxWriteStep: (file, error, cursor) -> 143 | # Ignore canceled uploads. 144 | return unless @xhrs[file.uid] 145 | 146 | if error 147 | file.setUploadError error 148 | @removeFile file, => 149 | @onStateChange.dispatch file 150 | return 151 | 152 | file.setUploadCursor cursor 153 | if cursor.offset is file.size 154 | @resumableUploadFinish file, => 155 | @onStateChange.dispatch file 156 | else 157 | @resumableUploadStep file, => 158 | @onStateChange.dispatch file 159 | 160 | # Called when an XHR uploading a file makes progress. 161 | # 162 | # @param {DropshipFile} file descriptor for the file getting uploaded 163 | # @param {XMLHttpRequet} xhr the XHR making progress 164 | # @param 165 | onXhrUploadProgress: (file, xhr, event) -> 166 | # Ignore canceled downloads. 167 | unless @xhrs[file.uid] 168 | xhr.abort() 169 | return 170 | 171 | uploadSize = if file.uploadCursor 172 | @uploadStepSize 173 | else 174 | file.size 175 | 176 | uploadedBytes = event.loaded 177 | totalBytes = if event.lengthComputable then event.total else null 178 | if totalBytes 179 | uploadOverhead = totalBytes - uploadSize 180 | uploadOverhead = 0 if uploadOverhead < 0 181 | uploadedBytes -= uploadOverhead 182 | uploadedBytes = 0 if uploadedBytes < 0 183 | 184 | if file.uploadCursor 185 | uploadedBytes += file.uploadCursor.offset 186 | file.setUploadProgress uploadedBytes - uploadOverhead 187 | @onStateChange.dispatch file 188 | 189 | # Removes one of the files tracked for upload. 190 | # 191 | # @private Called by cancelFile and onDropboxWrite. 192 | # @return {UploadController} this 193 | removeFile: (file, callback) -> 194 | if @files[file.uid] 195 | delete @xhrs[file.uid] 196 | delete @files[file.uid] 197 | callback() 198 | @ 199 | 200 | # Files above this size are uploaded using the resumable method. 201 | atomicUploadCutoff: 4 * 1024 * 1024 202 | 203 | # The size of a step in a multi-step upload. 204 | uploadStepSize: 4 * 1024 * 1024 205 | 206 | window.UploadController = UploadController 207 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | async = require 'async' 2 | {spawn, exec} = require 'child_process' 3 | fs = require 'fs-extra' 4 | glob = require 'glob' 5 | log = console.log 6 | path = require 'path' 7 | remove = require 'remove' 8 | watch = require 'watch' 9 | 10 | # Node 0.6 compatibility hack. 11 | unless fs.existsSync 12 | fs.existsSync = (filePath) -> path.existsSync filePath 13 | 14 | 15 | task 'build', -> 16 | vendor -> 17 | build() 18 | 19 | task 'release', -> 20 | vendor -> 21 | build -> 22 | release() 23 | 24 | task 'vendor', -> 25 | vendor() 26 | 27 | task 'clean', -> 28 | clean() 29 | 30 | task 'watch', -> 31 | build -> 32 | setupWatch() 33 | 34 | 35 | build = (callback) -> 36 | for dir in ['build', 'build/css', 'build/font', 'build/html', 'build/images', 37 | 'build/js', 'build/vendor', 'build/vendor/font', 38 | 'build/vendor/js'] 39 | fs.mkdirSync dir unless fs.existsSync dir 40 | 41 | commands = [] 42 | commands.push 'cp src/manifest.json build/' 43 | commands.push 'cp -r src/html build/' 44 | commands.push 'cp -r src/font build/' 45 | # TODO(pwnall): consider optipng 46 | commands.push 'cp -r src/images build/' 47 | for inFile in glob.sync 'src/less/**/*.less' 48 | continue if path.basename(inFile).match /^_/ 49 | outFile = inFile.replace(/^src\/less\//, 'build/css/'). 50 | replace(/\.less$/, '.css') 51 | commands.push "node_modules/less/bin/lessc --strict-imports #{inFile} " + 52 | "> #{outFile}" 53 | for inFile in glob.sync 'vendor/js/*.js' 54 | continue if inFile.match /\.min\.js$/ 55 | outFile = 'build/' + inFile 56 | commands.push "cp #{inFile} #{outFile}" 57 | for inFile in glob.sync 'vendor/font/*' 58 | outFile = 'build/' + inFile 59 | commands.push "cp #{inFile} #{outFile}" 60 | commands.push 'cp -r src/coffee build' 61 | commands.push 'node_modules/coffee-script/bin/coffee --output build/js ' + 62 | '--map --compile build/coffee/*.coffee' 63 | async.forEachSeries commands, run, -> 64 | callback() if callback 65 | 66 | release = (callback) -> 67 | for dir in ['release', 'release/css', 'release/html', 'release/images', 68 | 'release/js', 'release/vendor', 'release/vendor/font', 69 | 'release/vendor/js'] 70 | fs.mkdirSync dir unless fs.existsSync dir 71 | 72 | if fs.existsSync 'release/dropship-chrome.zip' 73 | fs.unlinkSync 'release/dropship-chrome.zip' 74 | 75 | # Remove the debug key from manifest.json 76 | json = fs.readFileSync 'build/manifest.json', encoding: 'utf8' 77 | json = json.replace(/^\s*"key":.*$/m, '') 78 | fs.writeFileSync 'release/manifest.json', json 79 | 80 | commands = [] 81 | # TODO(pwnall): consider a html minifier 82 | commands.push 'cp -r build/html release/' 83 | commands.push 'cp -r build/font release/' 84 | commands.push 'cp -r build/images release/' 85 | commands.push 'cp -r build/vendor/font release/vendor/' 86 | for inFile in glob.sync 'src/less/**/*.less' 87 | continue if path.basename(inFile).match /^_/ 88 | outFile = inFile.replace(/^src\/less\//, 'release/css/'). 89 | replace(/\.less$/, '.css') 90 | commands.push "node_modules/less/bin/lessc --compress --strict-imports " + 91 | "#{inFile} > #{outFile}" 92 | for inFile in glob.sync 'build/js/*.js' 93 | outFile = inFile.replace /^build\//, 'release/' 94 | commands.push 'node_modules/uglify-js/bin/uglifyjs --compress --mangle ' + 95 | "--output #{outFile} #{inFile}" 96 | for inFile in glob.sync 'vendor/js/*.min.js' 97 | outFile = 'release/' + inFile.replace(/\.min\.js$/, '.js') 98 | commands.push "cp #{inFile} #{outFile}" 99 | 100 | commands.push 'cd release && zip -r -9 -x "*.DS_Store" "*.sw*" @ ' + 101 | 'dropship-chrome.zip .' 102 | commands.push 'mv release/dropship-chrome.zip ./' 103 | 104 | async.forEachSeries commands, run, -> 105 | callback() if callback 106 | 107 | clean = (callback) -> 108 | removeReleaseCb = -> 109 | callback() if callback 110 | removeBuildCb = -> 111 | fs.exists 'release', (exists) -> 112 | if exists 113 | fs.remove 'release', removeReleaseCb 114 | else 115 | removeReleaseCb() 116 | fs.exists 'build', (exists) -> 117 | if exists 118 | fs.remove 'build', removeBuildCb 119 | else 120 | removeBuildCb() 121 | 122 | setupWatch = (callback) -> 123 | scheduled = false 124 | buildNeeded = false 125 | cleanNeeded = false 126 | onTick = -> 127 | scheduled = false 128 | if cleanNeeded 129 | buildNeeded = false 130 | cleanNeeded = false 131 | console.log "Doing a clean build" 132 | clean -> build() 133 | else if buildNeeded 134 | buildNeed = false 135 | console.log "Building" 136 | build() 137 | 138 | watch.createMonitor 'src/', (monitor) -> 139 | monitor.on 'created', (fileName) -> 140 | return unless path.basename(fileName)[0] is '.' 141 | buildNeeded = true 142 | unless scheduled 143 | scheduled = true 144 | process.nextTick onTick 145 | monitor.on 'changed', (fileName) -> 146 | return unless path.basename(fileName)[0] is '.' 147 | buildNeeded = true 148 | unless scheduled 149 | scheduled = true 150 | process.nextTick onTick 151 | monitor.on 'removed', (fileName) -> 152 | return unless path.basename(fileName)[0] is '.' 153 | cleanNeeded = true 154 | buildNeeded = true 155 | unless scheduled 156 | scheduled = true 157 | process.nextTick onTick 158 | 159 | vendor = (callback) -> 160 | dirs = ['vendor', 'vendor/js', 'vendor/less', 'vendor/font', 'vendor/tmp', 161 | 'vendor/less/font_awesome'] 162 | for dir in dirs 163 | fs.mkdirSync dir unless fs.existsSync dir 164 | 165 | downloads = [ 166 | ['https://cdnjs.cloudflare.com/ajax/libs/dropbox.js/0.10.3/dropbox.min.js', 167 | 'vendor/js/dropbox.min.js'], 168 | ['https://cdnjs.cloudflare.com/ajax/libs/dropbox.js/0.10.3/dropbox.js', 169 | 'vendor/js/dropbox.js'], 170 | 171 | # Zepto.js is a subset of jQuery. 172 | ['http://zeptojs.com/zepto.js', 'vendor/js/zepto.js'], 173 | ['http://zeptojs.com/zepto.min.js', 'vendor/js/zepto.min.js'], 174 | 175 | # Humanize for user-readable sizes. 176 | ['https://raw.github.com/taijinlee/humanize/0.0.9/humanize.js', 177 | 'vendor/js/humanize.js'], 178 | 179 | # Async.js for asynchronous iterators. 180 | ['https://raw.github.com/caolan/async/0.9.0/lib/async.js', 181 | 'vendor/js/async.js'], 182 | 183 | # URI.js for URL parsing. 184 | ['https://raw.github.com/medialize/URI.js/v1.14.1/src/URI.min.js', 185 | 'vendor/js/uri.min.js'], 186 | 187 | # FontAwesome for icons. 188 | ['https://github.com/FortAwesome/Font-Awesome/archive/v4.2.0.zip', 189 | 'vendor/tmp/font_awesome.zip'], 190 | ] 191 | 192 | async.forEachSeries downloads, download, -> 193 | # If a dropbox-js development tree happens to be checked out next to 194 | # the extension, copy the dropbox.js files from there. 195 | commands = [] 196 | if fs.existsSync '../dropbox-js/lib/dropbox.js' 197 | commands.push 'cp ../dropbox-js/lib/dropbox.js vendor/js/' 198 | if fs.existsSync '../dropbox-js/lib/dropbox.min.js' 199 | commands.push 'cp ../dropbox-js/lib/dropbox.min.js vendor/js/' 200 | 201 | # Minify humanize. 202 | unless fs.existsSync 'vendor/js/humanize.min.js' 203 | commands.push 'node_modules/uglify-js/bin/uglifyjs --compress ' + 204 | '--mangle --output vendor/js/humanize.min.js vendor/js/humanize.js' 205 | 206 | # Minify async.js. 207 | unless fs.existsSync 'vendor/js/async.min.js' 208 | commands.push 'node_modules/uglify-js/bin/uglifyjs --compress ' + 209 | '--mangle --output vendor/js/async.min.js vendor/js/async.js' 210 | 211 | # Use minified URI.js everywhere. 212 | unless fs.existsSync 'vendor/js/uri.js' 213 | commands.push 'cp vendor/js/uri.min.js vendor/js/uri.js' 214 | 215 | # Unpack fontawesome. 216 | unless fs.existsSync 'vendor/tmp/Font-Awesome-4.2.0/' 217 | commands.push 'unzip -qq -d vendor/tmp vendor/tmp/font_awesome' 218 | # Patch fontawesome inplace. 219 | # commands.push 'sed -i -e "/^@FontAwesomePath:/d" ' + 220 | # 'vendor/tmp/Font-Awesome-4.2.0/less/variables.less' 221 | 222 | async.forEachSeries commands, run, -> 223 | commands = [] 224 | 225 | # Copy fontawesome to vendor/. 226 | for inFile in glob.sync 'vendor/tmp/Font-Awesome-4.2.0/less/*.less' 227 | outFile = inFile.replace /^vendor\/tmp\/Font-Awesome-4\.2\.0\/less\//, 228 | 'vendor/less/font_awesome/' 229 | unless fs.existsSync outFile 230 | commands.push "cp #{inFile} #{outFile}" 231 | for inFile in glob.sync 'vendor/tmp/Font-Awesome-4.2.0/fonts/*' 232 | outFile = inFile.replace /^vendor\/tmp\/Font-Awesome-4\.2\.0\/fonts\//, 233 | 'vendor/font/' 234 | unless fs.existsSync outFile 235 | commands.push "cp #{inFile} #{outFile}" 236 | 237 | async.forEachSeries commands, run, -> 238 | callback() if callback 239 | 240 | 241 | _current_cmd = null 242 | run = (command, options, callback) -> 243 | if !callback and typeof options is 'function' 244 | callback = options 245 | options = {} 246 | else 247 | options or= {} 248 | if /^win/i.test(process.platform) # Awful windows hacks. 249 | command = command.replace(/\//g, '\\') 250 | cmd = spawn 'cmd', ['/C', command] 251 | else 252 | cmd = spawn '/bin/sh', ['-c', command] 253 | _current_cmd = cmd 254 | cmd.stdout.on 'data', (data) -> 255 | process.stdout.write data unless options.noOutput 256 | cmd.stderr.on 'data', (data) -> 257 | process.stderr.write data unless options.noOutput 258 | cmd.on 'exit', (code) -> 259 | _current_cmd = null 260 | if code isnt 0 and !options.noExit 261 | console.log "Non-zero exit code #{code} running\n #{command}" 262 | process.exit 1 263 | callback(code) if callback? 264 | null 265 | 266 | download = ([url, file], callback) -> 267 | if fs.existsSync file 268 | callback() if callback? 269 | return 270 | 271 | run "curl --fail -L -o #{file} #{url}", callback 272 | -------------------------------------------------------------------------------- /src/coffee/dropship_file.coffee: -------------------------------------------------------------------------------- 1 | # Model class that tracks one in-progress or downloaded file. 2 | class DropshipFile 3 | # @param {Object} options one or more of the attributes below 4 | # @option options {String} url URL where the file is downloaded from 5 | # @option options {String} httpMethod the verb used to download the file 6 | # @option options {Object} headers HTTP headers to be set 7 | # when downloading the file 8 | # @option options {Number} startedAt the time when the user asked to have the 9 | # file downloaded 10 | # @option options {String} uid identifying string, unique to an extension 11 | # instance 12 | # @option options {Number} size file's size, in bytes; this is only known 13 | # after the file's download starts 14 | # @option options {Dropbox.Http.UploadCursor} uploadCursor the progress of 15 | # this file's upload 16 | # @option options {String} dropboxPath the path of this file, relative to the 17 | # application's Dropbox folder 18 | # @option options {Number} state file's progress in the download / upload 19 | # process 20 | # @option options {String} errorText user-friendly message describing the 21 | # download/upload error 22 | constructor: (options) -> 23 | @url = options.url 24 | @httpMethod = options.httpMethod or 'GET' 25 | @headers = options.headers or {} 26 | @dropboxPath = options.dropboxPath or null 27 | @startedAt = options.startedAt or Date.now() 28 | @uid = options.uid or DropshipFile.randomUid() 29 | @size = options.size 30 | @size = null unless @size or @size is 0 31 | @uploadCursor = if options.uploadCursor 32 | new Dropbox.Http.UploadCursor options.uploadCursor 33 | else 34 | null 35 | @errorText = options.errorText or null 36 | @_state = options.state or DropshipFile.NEW 37 | 38 | @_json = null 39 | @_basename = null 40 | 41 | @_downloadedBytes = 0 42 | @_savedBytes = 0 43 | if @uploadCursor 44 | @_uploadedBytes = @uploadCursor.offset 45 | else 46 | @_uploadedBytes = 0 47 | @blob = null 48 | 49 | # @property {String} identifying string, unique to an extension instance 50 | uid: null 51 | 52 | # @property {String} URL where the file is downloaded from 53 | url: null 54 | 55 | # @property {Object} HTTP headers used to download the file 56 | headers: null 57 | 58 | # @property {String} HTTP method (verb) used to download the file 59 | httpMethod: null 60 | 61 | # @property {String} the path of this file, relative to the application's 62 | # Dropbox folder 63 | dropboxPath: null 64 | 65 | # @property {Number} the time when the user asked to have the file 66 | # downloaded; this should only be used for relative comparison among 67 | # DropshipFile instances on the same extension instance 68 | startedAt: null 69 | 70 | # @property {Number} the file's size, in bytes 71 | size: null 72 | 73 | # @property {String} errorText 74 | errorText: null 75 | 76 | # @return {Number} one of the DropshipFile constants indicating this file's 77 | # progress in the download / upload process 78 | state: -> @_state 79 | 80 | # @return {Object} JSON object that can be passed to the DropshipFile 81 | # constructor to build a clone for this file; intended to preserve the 82 | # file's contents 83 | json: -> 84 | @_json ||= 85 | url: @url, headers: @headers, httpMethod: @httpMethod, 86 | dropboxPath: @dropboxPath, startedAt: @startedAt, uid: @uid, 87 | uploadCursor: @uploadCursor and @uploadCursor.json(), 88 | size: @size, state: @_state, errorText: @errorText 89 | 90 | # @return {String} the file's name without the path, query string and 91 | # URL fragment 92 | basename: -> 93 | return @_basename if @_basename 94 | 95 | if @dropboxPath 96 | basename = @dropboxPath 97 | basename = basename.substring basename.lastIndexOf('/') + 1 98 | @_basename = basename 99 | else 100 | @_basename = @uploadBasename() 101 | 102 | # @return {String} the name used when uploading this file to Dropbox 103 | # URL fragment 104 | uploadBasename: -> 105 | basename = @url.split('#', 2)[0].split('?', 2)[0] 106 | while basename.substring(basename.length - 1) == '/' 107 | basename = basename.substring 0, basename.length - 1 108 | decodeURIComponent basename.substring(basename.lastIndexOf('/') + 1) 109 | 110 | # Called while the file is downloading. 111 | # 112 | # @param {Number} downloadedBytes should be smaller or equal to totalBytes, 113 | # if totalBytes is not null 114 | # @param {?Number} totalBytes the file's size, if known; null otherwise 115 | # @return {DropshipFile} this 116 | setDownloadProgress: (downloadedBytes, totalBytes) -> 117 | @_state = DropshipFile.DOWNLOADING 118 | @_downloadedBytes = downloadedBytes 119 | @size = totalBytes if totalBytes 120 | @_json = null 121 | @ 122 | 123 | # Called while the file is being saved to IndexedDB. 124 | # 125 | # @param {Number} savedBytes the number of bytes that have been already 126 | # written to IndexedDB 127 | # @return {DropshipFile} this 128 | setSaveProgress: (savedBytes) -> 129 | @_state = DropshipFile.SAVING 130 | @_savedBytes = savedBytes 131 | @ 132 | 133 | # Called while the file is uploading. 134 | # 135 | # @param {Number} uploadedBytes number of bytes that have been uploaded to 136 | # Dropbox 137 | # @return {DropshipFile} this 138 | setUploadProgress: (uploadedBytes) -> 139 | @_state = DropshipFile.UPLOADING 140 | @_uploadedBytes = uploadedBytes 141 | @_json = null 142 | @ 143 | 144 | # Called when a step in a multi-step upload completes. 145 | # 146 | # @param {Dropbox.Http.UploadCursor} cursor the upload's progress 147 | # @return {DropshipFile} this 148 | setUploadCursor: (cursor) -> 149 | @_state = DropshipFile.UPLOADING 150 | @uploadCursor = cursor 151 | @_uploadedBytes = cursor.offset 152 | @_json = null 153 | @ 154 | 155 | # @return {Number} the number of bytes that have been already downloaded 156 | downloadedBytes: -> 157 | if @_state is DropshipFile.DOWNLOADING 158 | @_downloadedBytes or 0 159 | else if @_state > DropshipFile.DOWNLOADING 160 | @size 161 | else 162 | 0 163 | 164 | # @return {Number} the number of bytes that have been alredy written to 165 | # IndexedDB 166 | savedBytes: -> 167 | if @_state is DropshipFile.SAVING 168 | @_savedBytes or 0 169 | else if @_state > DropshipFile.SAVING 170 | @size 171 | else 172 | 0 173 | 174 | # @return {Number} the number of bytes that have been alredy uploaded 175 | uploadedBytes: -> 176 | if @_state is DropshipFile.UPLOADING 177 | @_uploadedBytes or 0 178 | else if @_state > DropshipFile.UPLOADING 179 | @size 180 | else 181 | 0 182 | 183 | # Called when the file is fully saved to IndexedDB. 184 | # 185 | # @return {DropshipFile} this 186 | setSaveSuccess: -> 187 | @_state = DropshipFile.SAVED 188 | @ 189 | 190 | # Called when the Blob representing the file's content is available. 191 | # 192 | # @param {Blob} blob a Blob that has the file's contents 193 | # @return {DropshipFile} this 194 | setContents: (blob) -> 195 | @_state = DropshipFile.DOWNLOADED 196 | @_downloadedBytes = null 197 | @size = blob.size 198 | @blob = blob 199 | @_json = null 200 | @ 201 | 202 | # Called when a file's download ends due to an error. 203 | # 204 | # @param {Dropbox.ApiError} error the download error 205 | # @return {DropshipFile} this 206 | setDownloadError: (error) -> 207 | @_state = DropshipFile.ERROR 208 | @errorText = "Download error: #{error}" 209 | @_json = null 210 | @ 211 | 212 | # Called when a file's save to IndexedDB ends due to an error. 213 | # 214 | # @param {Error} error the IndexedDB error 215 | # @return {DropshipFile} this 216 | setSaveError: (error) -> 217 | @_state = DropshipFile.ERROR 218 | @errorText = "Disk error: #{error}" 219 | @_json = null 220 | @ 221 | 222 | # Called when a file's upload to Dropbox ends due to an error. 223 | # 224 | # @param {Dropbox.ApiError} error the Dropbox API server error 225 | # @return {DropshipFile} this 226 | setUploadError: (error) -> 227 | @_state = DropshipFile.ERROR 228 | @errorText = "Dropbox error: #{error}" 229 | @_json = null 230 | @ 231 | 232 | # Called when a file's upload to Dropbox completes. 233 | # 234 | # @param {Dropbox.Stat} stat the file's metadata in Dropbox 235 | # @return {DropshipFile} this 236 | setDropboxStat: (stat) -> 237 | @dropboxPath = stat.path 238 | @_basename = null # Invalidated so it's recomputed using dropboxPath. 239 | @_state = DropshipFile.UPLOADED 240 | @_uploadedBytes = null 241 | @blob = null 242 | @_json = null 243 | @ 244 | 245 | # Called when a file's download is canceled. 246 | setCanceled: -> 247 | @_state = DropshipFile.CANCELED 248 | @errorText = "Download canceled." 249 | @blob = null 250 | @_json = null 251 | @ 252 | 253 | # @return {Boolean} true if this file download / upload can be canceled 254 | canBeCanceled: -> 255 | @_state < DropshipFile.UPLOADED 256 | 257 | # @return {Boolean} true if this file download / upload can be hidden 258 | canBeHidden: -> 259 | @_state >= DropshipFile.UPLOADED 260 | 261 | # @return {Boolean} true if this file download / upload can be retried 262 | canBeRetried: -> 263 | @_state >= DropshipFile.UPLOADED 264 | 265 | # @return {String} a randomly generated unique ID 266 | @randomUid: -> 267 | Date.now().toString(36) + '_' + Math.random().toString(36).substring(2) 268 | 269 | # state() value before the file has started downloading 270 | @NEW: 1 271 | 272 | # state() value when the file is being downloaded from its origin 273 | @DOWNLOADING: 2 274 | 275 | # state() value when the file was downloaded, but hasn't started being 276 | # written to IndexedDB 277 | @DOWNLOADED: 3 278 | 279 | # state() value when the file is being written to IndexedDB 280 | @SAVING: 4 281 | 282 | # state() value when the file was written to IndexedDB, but hasn't started 283 | # being uploaded to Dropbox 284 | @SAVED: 5 285 | 286 | # state() value when the file is being uploaded to Dropbox 287 | @UPLOADING: 5 288 | 289 | # state() value after the file was uploaded to Dropbox 290 | @UPLOADED: 6 291 | 292 | # state() value when the file transfer failed due to an error 293 | @ERROR: 7 294 | 295 | # state() value when the file transfer was canceled 296 | @CANCELED: 8 297 | 298 | window.DropshipFile = DropshipFile 299 | -------------------------------------------------------------------------------- /src/coffee/popup.coffee: -------------------------------------------------------------------------------- 1 | class DownloadsView 2 | constructor: (@root) -> 3 | @$root = $ @root 4 | @$header = $ '#header', @$root 5 | @$userInfo = $ '#dropbox-info', @$header 6 | @$userNoInfo = $ '#dropbox-no-info', @$header 7 | @$userName = $ '#dropbox-name', @$userInfo 8 | @$userEmail = $ '#dropbox-email', @$userInfo 9 | @$optionsButton = $ '#options-button', @$header 10 | @$optionsButton.click (event) => @onOptionsClick event 11 | @$cleanupButton = $ '#cleanup-files-button', @$header 12 | @$cleanupButton.click (event) => @onCleanupClick event 13 | @$closeButton = $ '#close-window-button', @$header 14 | @$closeButton.click (event) => @onCloseClick event 15 | @$maximizeButton = $ '#maximize-window-button', @$header 16 | @$maximizeButton.click (event) => @onMaximizeClick event 17 | @$fileList = $ '#file-list', @$root 18 | @fileTemplate = $('#file-item-template', @$root).text() 19 | 20 | chrome.extension.onMessage.addListener (message) => @onMessage message 21 | 22 | @files = [] 23 | @$fileDoms = [] 24 | @fileIndexes = {} 25 | 26 | @reloadUserInfo() 27 | @updateFileList() 28 | 29 | # Updates the Dropbox user information in the view. 30 | reloadUserInfo: -> 31 | chrome.runtime.getBackgroundPage (eventPage) => 32 | eventPage.controller.dropboxChrome.userInfo (userInfo) => 33 | if userInfo.name 34 | @$userInfo.removeClass 'hidden' 35 | @$userNoInfo.addClass 'hidden' 36 | @$userName.text userInfo.name 37 | @$userEmail.text userInfo.email 38 | else 39 | @$userInfo.addClass 'hidden' 40 | @$userNoInfo.removeClass 'hidden' 41 | @ 42 | 43 | # Updates the entire file list view. 44 | updateFileList: -> 45 | chrome.runtime.getBackgroundPage (eventPage) => 46 | eventPage.controller.fileList.getFiles (fileMap) => 47 | files = (file for own uid, file of fileMap) 48 | # (b, a) is an easy hack to switch from ascending to descending sort 49 | files.sort (b, a) -> 50 | if a.startedAt != b.startedAt 51 | a.startedAt - b.startedAt 52 | else 53 | a.uid.localeCompare b.uid 54 | @files = files 55 | @fileIndexes = {} 56 | @fileIndexes[file.uid] = i for file, i in @files 57 | 58 | @renderFileList() 59 | @ 60 | 61 | # Updates one file in the view. 62 | updateFile: (fileUid) -> 63 | chrome.runtime.getBackgroundPage (eventPage) => 64 | eventPage.controller.fileList.getFiles (fileMap) => 65 | unless fileUid of @fileIndexes 66 | return @updateFileList() 67 | fileIndex = @fileIndexes[fileUid] 68 | unless @files[fileIndex].uid is fileUid 69 | return @updateFileList() 70 | 71 | file = fileMap[fileUid] 72 | @files[fileIndex] = file 73 | @updateFileDom @$fileDoms[fileIndex], file 74 | @ 75 | 76 | # Redraws the entire file list. 77 | renderFileList: -> 78 | @$fileList.empty() 79 | fileDoms = [] 80 | for file in @files 81 | $fileDom = $ @fileTemplate 82 | @updateFileDom $fileDom, file 83 | @$fileList.append $fileDom 84 | @wireFileDom $fileDom, file 85 | fileDoms.push $fileDom 86 | @$fileDoms = fileDoms 87 | @ 88 | 89 | # Sets up event listeners for the buttons in a file's view. 90 | wireFileDom: ($fileDom, file) -> 91 | $('.file-item-retry', $fileDom).click (event) => 92 | @onRetryClick event, file 93 | $('.file-item-cancel', $fileDom).click (event) => 94 | @onCancelClick event, file 95 | $('.file-item-hide', $fileDom).click (event) => 96 | @onHideClick event, file 97 | @ 98 | 99 | # Updates the DOM for a file entry to reflect the file's current state. 100 | updateFileDom: ($fileDom, file) -> 101 | # Status. 102 | switch file.state() 103 | when DropshipFile.NEW, DropshipFile.DOWNLOADING, DropshipFile.DOWNLOADED 104 | iconClass = 'fa fa-spinner fa-spin file-status-inprogress' 105 | iconTitle = 'Downloading' 106 | when DropshipFile.SAVING, DropshipFile.SAVED 107 | iconClass = 'fa fa-spinner fa-spin file-status-inprogress' 108 | iconTitle = 'Preparing to upload' 109 | when DropshipFile.UPLOADING 110 | iconClass = 'fa fa-spinner fa-spin file-status-inprogress' 111 | iconTitle = 'Saving to Dropbox' 112 | when DropshipFile.UPLOADED 113 | iconClass = 'fa fa-check file-status-done' 114 | iconTitle = 'Saved to Dropbox' 115 | when DropshipFile.CANCELED 116 | iconClass = 'fa fa-ban file-status-canceled' 117 | iconTitle = 'Canceled' 118 | when DropshipFile.ERROR 119 | iconClass = 'fa fa-exclamation-circle file-status-error' 120 | iconTitle = 'Something went wrong' 121 | $statusDom = $ '.file-item-status i', $fileDom 122 | # Changing the 's attributes resets icon animations, so blind writes are 123 | # bad. 124 | if $statusDom.attr('class') isnt iconClass 125 | $statusDom.attr 'class', iconClass 126 | if $statusDom.attr('title') isnt iconTitle 127 | $statusDom.attr 'title', iconTitle 128 | 129 | # Metadata. 130 | $fileDom.attr 'data-file-uid', file.uid 131 | $('.file-name', $fileDom).text file.basename() 132 | $('.file-item-link', $fileDom).text(file.url).attr 'href', file.url 133 | if file.size 134 | $('.file-size', $fileDom).text(humanize.filesize(file.size)). 135 | attr 'title', humanize.numberFormat(file.size, 0) 136 | 137 | # Progress bar and icon. 138 | state = file.state() 139 | if state >= DropshipFile.UPLOADED or state < DropshipFile.DOWNLOADING 140 | $('.file-progress-wrapper', $fileDom).addClass 'hidden' 141 | else 142 | $('.file-progress-wrapper', $fileDom).removeClass 'hidden' 143 | 144 | if file.size 145 | $('.file-item-progress', $fileDom).attr 'max', file.size * 146 | (@downloadProgressWeight + @saveProgressWeight + 147 | @uploadProgressWeight) 148 | else 149 | $('.file-item-progress', $fileDom).removeAttr 'max' 150 | 151 | $('.file-item-progress', $fileDom).attr 'value', 152 | file.downloadedBytes() * @downloadProgressWeight + 153 | file.savedBytes() * @saveProgressWeight + 154 | file.uploadedBytes() * @uploadProgressWeight 155 | 156 | if state >= DropshipFile.UPLOADING 157 | $('.file-item-progress', $fileDom).attr 'title', 158 | "#{humanize.numberFormat(file.uploadedBytes(), 0)} / " + 159 | "#{humanize.numberFormat(file.size, 0)} bytes uploaded to dropbox" 160 | iconClass = 'fa fa-cloud-upload' 161 | else if file.state() >= DropshipFile.SAVING 162 | $('.file-item-progress', $fileDom).attr 'title', 163 | "#{humanize.numberFormat(file.savedBytes(), 0)} / " + 164 | "#{humanize.numberFormat(file.size, 0)} bytes saved to disk" 165 | iconClass = 'fa fa-hdd-o' 166 | else 167 | $('.file-item-progress', $fileDom).attr 'title', 168 | "#{humanize.numberFormat(file.downloadedBytes(), 0)} / " + 169 | "#{humanize.numberFormat(file.size, 0)} bytes downloaded" 170 | iconClass = 'fa fa-download' 171 | $('.file-progress-wrapper i', $fileDom).attr 'class', iconClass 172 | 173 | # Error display. 174 | if file.state() >= DropshipFile.ERROR 175 | $('.file-item-error', $fileDom).removeClass 'hidden' 176 | $('.file-error-text', $fileDom).text file.errorText 177 | else 178 | $('.file-item-error', $fileDom).addClass 'hidden' 179 | 180 | # Actions. 181 | if file.canBeRetried() 182 | $('.file-item-retry', $fileDom).removeClass('hidden'). 183 | removeAttr 'disabled' 184 | else 185 | $('.file-item-retry', $fileDom).addClass 'hidden' 186 | if file.canBeCanceled() 187 | $('.file-item-cancel', $fileDom).removeClass('hidden'). 188 | removeAttr 'disabled' 189 | else 190 | $('.file-item-cancel', $fileDom).addClass 'hidden' 191 | if file.canBeHidden() 192 | $('.file-item-hide', $fileDom).removeClass('hidden'). 193 | removeAttr 'disabled' 194 | else 195 | $('.file-item-hide', $fileDom).addClass 'hidden' 196 | @ 197 | 198 | # Called when the user clicks on a file's Retry button. 199 | onRetryClick: (event, file) -> 200 | event.preventDefault() 201 | $(event.target).attr 'disabled', true 202 | chrome.runtime.getBackgroundPage (eventPage) -> 203 | eventPage.controller.retryFile file, -> null 204 | false 205 | 206 | # Called when the user clicks on a file's Cancel button. 207 | onCancelClick: (event, file) -> 208 | event.preventDefault() 209 | $(event.target).attr 'disabled', true 210 | chrome.runtime.getBackgroundPage (eventPage) -> 211 | eventPage.controller.cancelFile file, -> null 212 | false 213 | 214 | # Called when the user clicks on a file's Hide button. 215 | onHideClick: (event, file) -> 216 | event.preventDefault() 217 | $(event.target).attr 'disabled', true 218 | chrome.runtime.getBackgroundPage (eventPage) -> 219 | eventPage.controller.removeFile file, -> null 220 | false 221 | 222 | # Called when the user clicks on the cleanup downloads button. 223 | onCleanupClick: (event) -> 224 | event.preventDefault() 225 | chrome.runtime.getBackgroundPage (eventPage) -> 226 | eventPage.controller.removeUploadedFiles -> null 227 | false 228 | 229 | # called when the user clicks on the settings button. 230 | onOptionsClick: (event) -> 231 | chrome.tabs.create url: 'html/options.html', active: true, pinned: false 232 | window.close() 233 | 234 | # Called when the user clicks on the window close button. 235 | onCloseClick: (event) -> 236 | window.close() 237 | 238 | # Called when the user clicks on the window maximize button. 239 | onMaximizeClick: (event) -> 240 | chrome.tabs.create url: 'html/popup.html', active: true, pinned: false 241 | window.close() 242 | 243 | # Called when a Chrome extension internal message is received. 244 | onMessage: (message) -> 245 | switch message.notice 246 | when 'update_file' 247 | @updateFile message.fileUid 248 | when 'update_files' 249 | @updateFileList() 250 | when 'dropbox_auth' 251 | @reloadUserInfo() 252 | 253 | # How much of the progress bar is taken up by downloading. 254 | # 255 | # This is a heuristic. Weights don't need to add up to 1. 256 | downloadProgressWeight: 0.4 257 | 258 | # How much of the progress bar is taken up by saving the file to disk. 259 | # 260 | # This is a heuristic. Weights don't need to add up to 1. 261 | saveProgressWeight: 0.1 262 | 263 | # How much of the progress bar is taken up by uploading the file. 264 | # 265 | # This is a heuristic. Weights don't need to add up to 1. 266 | uploadProgressWeight: 0.5 267 | 268 | $ -> 269 | # The view is in the global namespace to facilitate debugging. 270 | window.view = new DownloadsView document.body 271 | -------------------------------------------------------------------------------- /src/coffee/event_page.coffee: -------------------------------------------------------------------------------- 1 | class EventPageController 2 | # @param {Dropbox.Chrome} dropboxChrome 3 | constructor: (@dropboxChrome) -> 4 | chrome.browserAction.onClicked.addListener => @onBrowserAction() 5 | chrome.contextMenus.onClicked.addListener (data) => @onContextMenu data 6 | chrome.runtime.onInstalled.addListener => 7 | @onInstall() 8 | @onStart() 9 | chrome.runtime.onStartup.addListener => @onStart() 10 | 11 | @dropboxChrome.onClient.addListener (client) => 12 | client.onAuthStepChange.addListener => @onDropboxAuthChange client 13 | client.onError.addListener (error) => @onDropboxError client, error 14 | 15 | @options = new Options 16 | 17 | @downloadController = new DownloadController 18 | @downloadController.onPermissionDenied.addListener => 19 | @errorNotice 'Download canceled due to denied permissions' 20 | @downloadController.onStateChange.addListener (file) => 21 | @onFileStateChange file 22 | 23 | @uploadController = new UploadController @dropboxChrome, @options 24 | @uploadController.onStateChange.addListener (file) => 25 | @onFileStateChange file 26 | 27 | @fileList = new DropshipList 28 | @fileList.onDbError.addListener (errorText) => 29 | @errorNotice errorText 30 | @fileList.onStateChange.addListener (file) => 31 | @onFileStateChange file 32 | @restoreFiles -> null 33 | 34 | # Called by Chrome when the user installs the extension. 35 | onInstall: -> 36 | null 37 | 38 | # Called by Chrome when the user installs the extension or starts Chrome. 39 | onStart: -> 40 | # NOTE: this was done in onInstall before, but users complained that the 41 | # context menu item disappeared after a while, presumably when the 42 | # event page was destroyed 43 | try 44 | chrome.contextMenus.create 45 | id: 'download', title: 'Upload to Dropbox', enabled: false, 46 | contexts: ['page', 'frame', 'link', 'image', 'video', 'audio'] 47 | catch chromeError 48 | # Context menu already created. 49 | null 50 | 51 | @dropboxChrome.client (client) => 52 | @onDropboxAuthChange client 53 | 54 | # Called by Chrome when the user clicks the browser action. 55 | onBrowserAction: -> 56 | @dropboxChrome.client (client) => 57 | if client.isAuthenticated() 58 | # Chrome did not show up the popup for some reason. Do it here. 59 | chrome.tabs.create url: 'html/popup.html', active: true, pinned: false 60 | 61 | credentials = client.credentials() 62 | if credentials.authState 63 | # The user clicked our button while we're signing him/her into Dropbox. 64 | # We can consider that the sign-up failed and try again. Most likely, 65 | # the user closed the Dropbox authorization tab. 66 | client.reset() 67 | 68 | # Start the sign-in process. 69 | @signIn -> null 70 | 71 | # Called by Chrome when the user clicks the extension's context menu item. 72 | onContextMenu: (clickData) -> 73 | url = null 74 | referrer = null 75 | if clickData.srcUrl or clickData.linkUrl 76 | url = clickData.linkUrl or clickData.srcUrl 77 | referrer = clickData.frameUrl or clickData.pageUrl 78 | else if clickData.frameUrl 79 | url = clickData.frameUrl 80 | referrer = clickData.pageUrl 81 | else if clickData.pageUrl 82 | url = clickData.pageUrl 83 | # TODO(pwnall): see if we can get the page referrer 84 | referrer = clickData.pageUrl 85 | else 86 | # This should never happen. At the very least, pageUrl should always be 87 | # set. If it does happen, there needs to be some sort of error message 88 | # here. 89 | return 90 | 91 | file = new DropshipFile url: url, headers: { Referer: referrer } 92 | @downloadController.addFile file, => 93 | @fileList.addFile file, => 94 | chrome.extension.sendMessage notice: 'update_files' 95 | 96 | # Called when the Dropbox authentication state changes. 97 | onDropboxAuthChange: (client) -> 98 | # Update the badge to reflect the current authentication state. 99 | if client.isAuthenticated() 100 | chrome.contextMenus.update 'download', enabled: true 101 | chrome.browserAction.setPopup popup: 'html/popup.html' 102 | chrome.browserAction.setTitle title: "Signed in" 103 | chrome.browserAction.setBadgeText text: '' 104 | else 105 | chrome.contextMenus.update 'download', enabled: false 106 | chrome.browserAction.setPopup popup: '' 107 | 108 | credentials = client.credentials() 109 | if credentials.authState 110 | chrome.browserAction.setTitle title: 'Signing in...' 111 | chrome.browserAction.setBadgeText text: '...' 112 | chrome.browserAction.setBadgeBackgroundColor color: '#DFBF20' 113 | else 114 | chrome.browserAction.setTitle title: 'Click to sign into Dropbox' 115 | chrome.browserAction.setBadgeText text: '?' 116 | chrome.browserAction.setBadgeBackgroundColor color: '#DF2020' 117 | 118 | chrome.extension.sendMessage notice: 'dropbox_auth' 119 | 120 | # Resumes the ongoing downloads / uploads. 121 | restoreFiles: (callback) -> 122 | @fileList.getFiles (files) => 123 | barrierCount = 1 124 | barrier = -> 125 | barrierCount -= 1 126 | callback() if barrierCount is 0 127 | 128 | for own uid, file of files 129 | switch file.state() 130 | when DropshipFile.UPLOADED, DropshipFile.ERROR, DropshipFile.CANCELED 131 | # Nothing to do. 132 | else 133 | barrierCount += 1 134 | @fileList.getFileContents file, (error, blob) => 135 | if blob 136 | file.blob = blob 137 | @uploadController.addFile file, barrier 138 | else 139 | @downloadController.addFile file, barrier 140 | barrier() 141 | 142 | # Called when the user asks to have a file download / upload canceled. 143 | cancelFile: (file, callback) -> 144 | switch file.state() 145 | when DropshipFile.DOWNLOADING 146 | @downloadController.cancelFile file, => 147 | file.setCanceled() 148 | @fileList.updateFileState file, (error) => 149 | chrome.extension.sendMessage( 150 | notice: 'update_file', fileUid: file.uid) 151 | callback() 152 | when DropshipFile.SAVING 153 | file.setCanceled() 154 | @fileList.cancelFileContents file, => 155 | @fileList.updateFileState file, (error) => 156 | chrome.extension.sendMessage( 157 | notice: 'update_file', fileUid: file.uid) 158 | callback() 159 | when DropshipFile.UPLOADING 160 | @uploadController.cancelFile file, => 161 | file.setCanceled() 162 | @fileList.updateFileState file, (error) => 163 | chrome.extension.sendMessage( 164 | notice: 'update_file', fileUid: file.uid) 165 | callback() 166 | else # The file got in a different state. 167 | callback() 168 | @ 169 | 170 | # Called when the user asks to have a file's info removed from the list. 171 | removeFile: (file, callback) -> 172 | switch file.state() 173 | when DropshipFile.UPLOADED, DropshipFile.ERROR, DropshipFile.CANCELED 174 | @fileList.removeFileState file, -> 175 | chrome.extension.sendMessage notice: 'update_files' 176 | callback() 177 | else # The file got in a different state. 178 | callback() 179 | @ 180 | 181 | # Called when the user asks to have a download / upload re-attempted. 182 | retryFile: (file, callback) -> 183 | switch file.state() 184 | when DropshipFile.UPLOADED, DropshipFile.ERROR, DropshipFile.CANCELED 185 | @fileList.getFileContents file, (error, blob) => 186 | if blob 187 | file.blob = blob 188 | @uploadController.addFile file, callback 189 | else 190 | @downloadController.addFile file, callback 191 | 192 | removeUploadedFiles: (callback) -> 193 | @fileList.getFiles (files) => 194 | uploadedFiles = [] 195 | for uid, file of files 196 | if file.state() is DropshipFile.UPLOADED 197 | uploadedFiles.push file 198 | 199 | @fileList.removeFileStates uploadedFiles, -> 200 | chrome.extension.sendMessage notice: 'update_files' 201 | callback() 202 | @ 203 | 204 | # Called when the user wishes to sign out of Dropbox. 205 | signOut: (callback) -> 206 | @fileList.getFiles (files) => 207 | # After all transfers are stopped, sign out and delete everything. 208 | barrierCount = 1 209 | barrier = => 210 | barrierCount -= 1 211 | return if barrierCount isnt 0 212 | dropboxChrome.signOut => 213 | @fileList.removeDb => 214 | chrome.extension.sendMessage notice: 'reset_files' 215 | callback() 216 | 217 | # Stop all transfers. 218 | for own uid, file of files 219 | switch file.state() 220 | when DropshipFile.DOWNLOADING 221 | barrierCount += 1 222 | @downloadController.removeFile file, => barrier() 223 | when DropshipFile.UPLOADING 224 | barrierCount += 1 225 | @uploadController.removeFile file, => barrier() 226 | when DropshipFile.SAVING 227 | barrierCount += 1 228 | @fileController.cancelSetFileContents file, => barrier() 229 | barrier() 230 | 231 | # Called when the user wishes to sign into Dropbox. 232 | signIn: (callback) -> 233 | @dropboxChrome.client (client) -> 234 | client.authenticate (error) -> 235 | client.reset() if error 236 | callback() 237 | 238 | # Called when the Dropbox API server returns an error. 239 | onDropboxError: (client, error) -> 240 | @errorNotice "Something went wrong while talking to Dropbox: #{error}" 241 | 242 | # Called when a file's state changes. 243 | onFileStateChange: (file) -> 244 | @fileList.updateFileState file, (error) => 245 | return if error 246 | chrome.extension.sendMessage notice: 'update_file', fileUid: file.uid 247 | 248 | switch file.state() 249 | when DropshipFile.DOWNLOADED 250 | @fileList.setFileContents file, file.blob, (error) => 251 | if error 252 | chrome.extension.sendMessage( 253 | notice: 'update_file', fileUid: file.uid) 254 | when DropshipFile.SAVED 255 | @uploadController.addFile file, => 256 | chrome.extension.sendMessage( 257 | notice: 'update_file', fileUid: file.uid) 258 | when DropshipFile.UPLOADED 259 | @fileList.removeFileContents file, (error) -> null 260 | 261 | # Shows a desktop notification informing the user that an error occurred. 262 | errorNotice: (errorText) -> 263 | webkitNotifications.createNotification 'images/icon48.png', 264 | 'Download to Dropbox', errorText 265 | 266 | 267 | dropboxChrome = new Dropbox.Chrome key: 'wlnp1hsavamu4ue' 268 | window.controller = new EventPageController dropboxChrome 269 | -------------------------------------------------------------------------------- /src/coffee/dropship_list.coffee: -------------------------------------------------------------------------------- 1 | # Model class that tracks all the in-progress and completed downloads. 2 | # 3 | # This class is responsible for persisting the files' contents and metadata to 4 | # IndexedDB. 5 | class DropshipList 6 | constructor: -> 7 | @_files = null 8 | @_db = null 9 | @_dbLoadCallbacks = null 10 | @_fileGetCallbacks = null 11 | @_iops = {} 12 | @onDbError = new Dropbox.Util.EventSource 13 | @onStateChange = new Dropbox.Util.EventSource 14 | 15 | # @property {Dropbox.Util.EventSource} fires non-cancelable events 16 | # when a database error occurs; listeners should update the UI to reflect 17 | # the error 18 | onDbError: null 19 | 20 | # @property {Dropbox.Util.EventSource} non-cancelable event 21 | # fired when IndexedDB file I/O makes progress, completes, or stops due to 22 | # an error; this event does not fire when the file I/O is canceled 23 | onStateChange: null 24 | 25 | # Adds a file to the list of files to be downloaded / uploaded. 26 | # 27 | # @param {DropshipFile} file the file to be added 28 | # @param {function(Boolean)} callback called when the file's metadata is 29 | # persisted; the callback argument is true if an error occurred 30 | # @return {DropshipList} this 31 | addFile: (file, callback) -> 32 | @db (db) => 33 | @getFiles (files) => 34 | transaction = db.transaction 'metadata', 'readwrite' 35 | metadataStore = transaction.objectStore 'metadata' 36 | request = metadataStore.put file.json() 37 | transaction.oncomplete = => 38 | files[file.uid] = file 39 | callback false 40 | transaction.onerror = (event) => 41 | @handleDbError event 42 | callback true 43 | @ 44 | 45 | # Updates the persisted metadata for a file to reflect changes. 46 | # 47 | # @param {DropshipFile} file the file whose state changed 48 | # @param {function(Boolean)} callback called when the file's metadata is 49 | # persisted; the callback argument is true if an error occurred 50 | # @return {DropshipList} this 51 | updateFileState: (file, callback) -> 52 | @db (db) => 53 | @getFiles (files) => 54 | transaction = db.transaction 'metadata', 'readwrite' 55 | metadataStore = transaction.objectStore 'metadata' 56 | request = metadataStore.put file.json() 57 | transaction.oncomplete = => 58 | files[file.uid] = file 59 | callback false 60 | transaction.onerror = (event) => 61 | @handleDbError event 62 | callback true 63 | @ 64 | 65 | # Removes the metadata for a file. 66 | # 67 | # @param {DropshipFile} file the file to be removed 68 | # @param {function(Boolean)} callback called when the file's data is 69 | # removed; the callback argument is true if an error occurred 70 | # @return {DropshipList} this 71 | removeFileState: (file, callback) -> 72 | @removeFileStates [file], callback 73 | 74 | # Removes the metadata for a set of files. 75 | # 76 | # @param {Array} files the files to be removed 77 | # @param {function(Boolean)} callback called when the file's data is 78 | # removed; the callback argument is true if an error occurred 79 | # @return {DropshipList} this 80 | removeFileStates: (files, callback) -> 81 | @db (db) => 82 | @getFiles (_files) => 83 | transaction = db.transaction 'metadata', 'readwrite' 84 | metadataStore = transaction.objectStore 'metadata' 85 | for file in files 86 | metadataStore.delete file.uid 87 | transaction.oncomplete = => 88 | for file in files 89 | delete _files[file.uid] 90 | callback null 91 | transaction.onerror = (event) => 92 | @handleDbError event 93 | callback event.target.error 94 | @ 95 | 96 | # The mapping between file IDs and completed / in-progress file operations. 97 | # 98 | # @param {function(Object)} callback called with a 99 | # consistent snapshot of the in-progress and completed download files 100 | # @return {DropshipList} this 101 | getFiles: (callback) -> 102 | if @_files 103 | callback @_files 104 | return @ 105 | 106 | if @_fileGetCallbacks isnt null 107 | @_fileGetCallbacks.push callback 108 | return @ 109 | 110 | @_fileGetCallbacks = [callback] 111 | files = {} 112 | @loadFiles files, (error) => 113 | @_files = files 114 | callbacks = @_fileGetCallbacks 115 | @_fileGetCallbacks = null 116 | callback files for callback in callbacks 117 | 118 | # Loads the metadata for all the downloaded / uploaded files in memory. 119 | # 120 | # @private Called by getFiles. 121 | # @param {Object} results receives the file metadata, 122 | # keyed by file IDs 123 | # @param {function(Boolean)} callback called when the metadata finished 124 | # loading; the callback argument is true if something went wrong and the 125 | # results array might contain metadata for all the files 126 | loadFiles: (results, callback) -> 127 | @db (db) => 128 | transaction = db.transaction 'metadata', 'readonly' 129 | metadataStore = transaction.objectStore 'metadata' 130 | cursor = metadataStore.openCursor null, 'next' 131 | cursor.onsuccess = (event) => 132 | cursor = event.target.result 133 | if cursor and cursor.key 134 | request = metadataStore.get cursor.key 135 | request.onsuccess = (event) => 136 | json = event.target.result 137 | file = new DropshipFile json 138 | results[file.uid] = file 139 | cursor.continue() 140 | request.onerror = (event) => 141 | @handleDbError event 142 | callback true 143 | else 144 | callback false 145 | cursor.onerror = (event) => 146 | @handleDbError event 147 | callback true 148 | 149 | # Stores a file's contents in the database. 150 | # 151 | # @param {DropshipFile} file the file whose contents changed 152 | # @param {Blob} blob the file's contents 153 | # @param {function(?Error)} callback called when the file's contents is 154 | # persisted; the callback argument is true if an error occurred 155 | # @return {DropshipList} this 156 | setFileContents: (file, blob, callback) -> 157 | file.setSaveProgress 0 158 | 159 | fileOffset = 0 160 | blockId = 0 161 | blockLoop = => 162 | # Special case: we store empty files as 1 empty blob. 163 | # This lets us distinguish between a non-existing blob and an empty one. 164 | done = blockId isnt 0 and fileOffset >= file.size 165 | if done 166 | file.setSaveSuccess() 167 | @onStateChange.dispatch file 168 | return callback(null) 169 | 170 | if fileOffset + @blockSize >= blob.size 171 | currentBlockSize = blob.size - fileOffset 172 | else 173 | currentBlockSize = @blockSize 174 | 175 | blockBlob = blob.slice fileOffset, fileOffset + currentBlockSize 176 | @setFileBlock file, blockId, blockBlob, (error) => 177 | if error 178 | file.setSaveError error 179 | @onStateChange.dispatch file 180 | return callback(error) 181 | blockId += 1 182 | fileOffset += currentBlockSize 183 | file.setSaveProgress fileOffset 184 | @onStateChange.dispatch file 185 | blockLoop() 186 | blockLoop() 187 | @ 188 | 189 | # Stores a block of the file's contents in the database. 190 | # 191 | # @param {DropshipFile} file the file whose contents is being stored 192 | # @param {Number} blockId 0-based block sequence number 193 | # @param {Blob} blockBlob the contents of the file blob; this is not a Blob 194 | # for the entire file 195 | # @param {function(?Error)} callback called when the file's contents is 196 | # persisted; the callback argument is non-null if an error occurred 197 | # @return {DropshipList} this 198 | setFileBlock: (file, blockId, blockBlob, callback) -> 199 | @db (db) => 200 | blobKey = @fileBlockKey file, blockId 201 | transaction = db.transaction 'blobs', 'readwrite' 202 | blobStore = transaction.objectStore 'blobs' 203 | try 204 | request = blobStore.put blockBlob, blobKey 205 | transaction.oncomplete = => 206 | callback null 207 | transaction.onerror = (event) => 208 | callback event.target.error 209 | catch e 210 | # Workaround for http://crbug.com/108012 211 | reader = new FileReader 212 | reader.onloadend = => 213 | return unless reader.readyState == FileReader.DONE 214 | string = reader.result 215 | transaction = db.transaction 'blobs', 'readwrite' 216 | blobStore = transaction.objectStore 'blobs' 217 | blobStore.put string, blobKey 218 | transaction.oncomplete = => 219 | callback null 220 | transaction.onerror = (event) => 221 | callback event.target.error 222 | reader.onerror = (event) => 223 | callback event.target.error 224 | reader.readAsBinaryString blockBlob 225 | 226 | # Cancels any pending IndexedDB operation involing a file. 227 | cancelFileContents: (file, callback) -> 228 | # TODO(pwnall): implement 229 | callback() 230 | @ 231 | 232 | # The IndexedDB key for a file's block. 233 | # 234 | # @param {DropshipFile} file the file that the block belongs to 235 | # @param {Number} blockId 0-based block sequence number 236 | # @return {String} the key associated with the file block in the IndexedDB 237 | # "blobs" table 238 | fileBlockKey: (file, blockId) -> 239 | # Padding 240 | stringId = blockId.toString 36 241 | while stringId.length < 8 242 | stringId = "0" + stringId 243 | 244 | # - comes right before all valid fileUid symbols in ASCII. 245 | "#{file.uid}-#{stringId}" 246 | 247 | # An upper bound for the IndexedDB keys for a file's blocks. 248 | fileMaxBlockKey: (file) -> 249 | # | comes after all blockId symbols in ASCII. 250 | "#{file.uid}-|" 251 | 252 | # Retrieves a file's contents from the database. 253 | # 254 | # @param {DropshipFile} file the file whose contents will be retrieved 255 | # @param {function(?Error, ?Blob)} callback called when the file's contents 256 | # is available; the argument will be null if the file's contents was not 257 | # found in the database 258 | # @return {DropshipList} this 259 | getFileContents: (file, callback) -> 260 | blockBlobs = [] 261 | fileOffset = 0 262 | blockId = 0 263 | blockLoop = => 264 | # Special case: we store empty files as 1 empty blob. 265 | # This lets us distinguish between a non-existing blob and an empty one. 266 | done = blockId isnt 0 and fileOffset >= file.size 267 | if done 268 | # NOTE: not reporting save success, the fetcher is responsible for 269 | # setting things up 270 | @onStateChange.dispatch file 271 | return callback(null, new Blob(blockBlobs, type: blockBlobs[0].type)) 272 | 273 | @getFileBlock file, blockId, (error, blockBlob) => 274 | if error 275 | # Read error. 276 | file.setSaveError error 277 | @onStateChange.dispatch file 278 | return callback(error) 279 | if blockBlob is null 280 | # Missing block, so report file-not-found. 281 | return callback(null, null) 282 | blockBlobs.push blockBlob 283 | blockId += 1 284 | fileOffset += blockBlob.size 285 | file.setSaveProgress fileOffset 286 | @onStateChange.dispatch file 287 | blockLoop() 288 | blockLoop() 289 | @ 290 | 291 | # Retrieves a block of the file's contents from the database. 292 | # 293 | # @param {DropshipFile} file the file whose contents will be retrieved 294 | # @param {Number} blockId 0-based block sequence number 295 | # @param {function(?Error, ?Blob)} callback called when the block's contents 296 | # is available; if the block is not found in the database, both the error 297 | # and the blob arguments will be null 298 | # @return {DropshipList} this 299 | getFileBlock: (file, blockId, callback) -> 300 | @db (db) => 301 | blobKey = @fileBlockKey file, blockId 302 | transaction = db.transaction 'blobs', 'readonly' 303 | blobStore = transaction.objectStore 'blobs' 304 | request = blobStore.get blobKey 305 | request.onsuccess = (event) => 306 | blockBlob = event.target.result 307 | unless blockBlob? 308 | # Incomplete save. 309 | return callback(null, null) 310 | 311 | # Workaround for http://crbug.com/108012 312 | if typeof blockBlob is 'string' 313 | string = blockBlob 314 | view = new Uint8Array string.length 315 | for i in [0...string.length] 316 | view[i] = string.charCodeAt(i) & 0xFF 317 | blockBlob = new Blob [view], type: 'application/octet-stream' 318 | callback null, blockBlob 319 | request.onerror = (event) => 320 | callback event.target.error 321 | 322 | # Removes a file's contents from the database. 323 | # 324 | # @param {DropshipFile} file the file whose contents will be removed 325 | # @param {function(Boolean)} callback called when the file's contents is 326 | # removed from the database; the callback argument is true if an error 327 | # occurred 328 | # @return {DropshipList} this 329 | removeFileContents: (file, callback) -> 330 | @db (db) => 331 | transaction = db.transaction 'blobs', 'readwrite' 332 | blobStore = transaction.objectStore 'blobs' 333 | keyRange = IDBKeyRange.bound @fileBlockKey(file, 0), 334 | @fileMaxBlockKey(file) 335 | cursor = blobStore.openCursor keyRange, 'next' 336 | cursor.onsuccess = (event) => 337 | cursor = event.target.result 338 | if cursor and cursor.key 339 | request = cursor.delete() 340 | request.onsuccess = (event) => 341 | cursor.continue() 342 | request.onerror = (event) => 343 | callback event.target.error 344 | else 345 | callback null 346 | cursor.onerror = (event) => 347 | callback event.target.error 348 | 349 | # Removes the contents of files whose metadata is missing. 350 | # 351 | # File contents and metadata is managed separately. If an attempt to remove a 352 | # file's contents Blob fails, but the metadata remove succeeds, the Blob 353 | # becomes stranded, as it will never be accessed again. Vacuuming removes 354 | # stranded Blobs so the database size doesn't keep growing. 355 | # 356 | # @param {function(Boolean)} callback called when the vacuuming completes; 357 | # the callback argument is true if an error occurred 358 | # @return {DropshopList} this 359 | vacuumFileContents: (callback) -> 360 | # TODO(pwnall): implement early exit using count() on blobs and metadata 361 | # TODO(pwnall): implement blob enumeration and kill dangling blobs 362 | callback() 363 | @ 364 | 365 | # @param {function(Boolean)} callback called when the vacuuming completes; 366 | # the callback argument is true if an error occurred 367 | # @return {DropshopList} this 368 | removeDb: (callback) -> 369 | @db (db) => 370 | @getFiles (files) => 371 | db.close() if db 372 | request = indexedDB.deleteDatabase @dbName 373 | request.oncomplete = => 374 | @_db = null 375 | @_files = null 376 | callback false 377 | request.onerror = (event) => 378 | @onDbError.dispatch event.target.error 379 | @_db = null 380 | @_files = null 381 | callback true 382 | 383 | # The IndexedDB database caching this extension's files. 384 | # 385 | # @param {function(IDBDatabase)} callback called when the database is ready 386 | # for use 387 | # @return {DropshipList} this 388 | db: (callback) -> 389 | if @_db 390 | callback @_db 391 | return @ 392 | 393 | # Queue up the callbacks while the database is being opened. 394 | if @_dbLoadCallbacks isnt null 395 | @_dbLoadCallbacks.push callback 396 | return @ 397 | @_dbLoadCallbacks = [callback] 398 | 399 | request = indexedDB.open @dbName, @dbVersion 400 | request.onsuccess = (event) => 401 | @openedDb event.target.result 402 | request.onupgradeneeded = (event) => 403 | db = event.target.result 404 | @migrateDb db, event.target.transaction, (error) => 405 | if error 406 | @openedDb null 407 | else 408 | @openedDb db 409 | request.onerror = (event) => 410 | @handleDbError event 411 | @openedDb null 412 | @ 413 | 414 | # Called when the IndexedDB is available for use. 415 | # 416 | # @private Called by handlers to IndexedDB events. 417 | # @param {IDBDatabase} db 418 | # @return {DropshipList} this 419 | openedDb: (db) -> 420 | return unless @_dbLoadCallbacks 421 | 422 | @_db = db 423 | callbacks = @_dbLoadCallbacks 424 | @_dbLoadCallbacks = null 425 | callback db for callback in callbacks 426 | @ 427 | 428 | # Sets up the IndexedDB schema. 429 | # 430 | # @private Called by the IndexedDB API. 431 | # 432 | # @param {IDBDatabase} db the database connection 433 | # @param {IDBTransaction} transaction the 'versionchange' transaction 434 | # @param {function()} callback called when the database is migrated to the 435 | # latest schema version 436 | # @return {DropshipList} this 437 | migrateDb: (db, transaction, callback) -> 438 | if db.objectStoreNames.contains 'blobs' 439 | db.deleteObjectStore 'blobs' 440 | db.createObjectStore 'blobs' 441 | if db.objectStoreNames.contains 'metadata' 442 | db.deleteObjectStore 'metadata' 443 | db.createObjectStore 'metadata', keyPath: 'uid' 444 | transaction.oncomplete = => 445 | callback false 446 | transaction.onerror = (event) => 447 | @handleDbError event 448 | callback true 449 | @ 450 | 451 | # Reports IndexedDB errors. 452 | # 453 | # The best name for this method would have been 'onDbError', but that's taken 454 | # by a public API element. 455 | # 456 | # @param {#target, #target.error} event the IndexedDB error event 457 | handleDbError: (event) -> 458 | error = event.target.error 459 | # TODO(pwnall): better error string 460 | errorString = "IndexedDB error: #{error}" 461 | @onDbError.dispatch errorString 462 | 463 | # IndexedDB database name. This should not change. 464 | dbName: 'dropship_files' 465 | 466 | # IndexedDB schema version. 467 | dbVersion: 1 468 | 469 | # The size of an atomic IndexedDB read / write and of a file upload chunk. 470 | blockSize: 1 * 1024 * 1024 471 | 472 | window.DropshipList = DropshipList 473 | --------------------------------------------------------------------------------