├── .gitignore ├── .bowerrc ├── wishlist ├── README.md ├── InboxOutbox.md ├── open.js ├── public_user_stores.js ├── share.js └── doc │ └── open.html ├── stuff ├── README ├── shortcuts.md ├── backbone-hoodie.coffee └── spine-hoodie.coffee ├── .travis.yml ├── vendor ├── .gitignore ├── jquery │ ├── component.json │ ├── composer.json │ └── jquery-migrate.min.js ├── prism │ ├── prism.css │ └── prism.js └── jasmine │ └── lib │ └── jasmine-core │ └── jasmine.css ├── component.json ├── test ├── mocks │ ├── bulk_update_response.mock.coffee │ ├── changed_docs.mock.coffee │ ├── changes_response.mock.coffee │ └── hoodie.mock.coffee ├── lib │ ├── phantomjs_console.coffee │ ├── jasmine-helpers.coffee │ └── phantomjs_test_runner.coffee ├── specs │ ├── core │ │ ├── config.spec.coffee │ │ ├── email.spec.coffee │ │ └── store.spec.coffee │ ├── events.spec.coffee │ ├── extensions │ │ ├── user.spec.coffee │ │ └── share.spec.coffee │ └── hoodie.spec.coffee └── index.html ├── src ├── core │ ├── errors.coffee │ ├── config.coffee │ ├── email.coffee │ ├── account_remote.coffee │ └── store.coffee ├── extensions │ ├── global.coffee │ ├── user.coffee │ ├── share.coffee │ └── share_instance.coffee ├── events.coffee └── hoodie.coffee ├── doc ├── extensions │ ├── global.html │ └── user.html ├── core │ ├── errors.html │ ├── email.html │ ├── config.html │ └── account_remote.html └── events.html ├── quickstart_for_developers.md ├── README.md ├── Cakefile └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | TODO.md 3 | compiled -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory" : "vendor" 3 | } 4 | -------------------------------------------------------------------------------- /wishlist/README.md: -------------------------------------------------------------------------------- 1 | dream APIs ... what's yours? 2 | -------------------------------------------------------------------------------- /stuff/README: -------------------------------------------------------------------------------- 1 | Just sayin' 2 | ============= 3 | 4 | Don't ask what hoodie can do for you. 5 | Ask what you can do for hoodie. -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_script: 2 | - npm install coffee-script 3 | - coffee -c -b -o compiled . 4 | script: phantomjs test/lib/phantomjs_test_runner.coffee test/index.html -------------------------------------------------------------------------------- /vendor/.gitignore: -------------------------------------------------------------------------------- 1 | jasmine/* 2 | !jasmine/lib 3 | jasmine/lib/* 4 | !jasmine/lib/jasmine-core 5 | jasmine/lib/jasmine-core/* 6 | !jasmine/lib/jasmine-core/jasmine.css 7 | !jasmine/lib/jasmine-core/jasmine.js 8 | !jasmine/lib/jasmine-core/jasmine-html.js -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "hoodie", 3 | "version" : "0.1.19", 4 | "main" : ["./hoodie.js"], 5 | "dependencies": { 6 | "jquery": "~1.9.1" 7 | }, 8 | "ignore": [ 9 | "test/", 10 | "wishlist/", 11 | "stuff/", 12 | "src/", 13 | "compiled/", 14 | ".gitignore", 15 | "Cakefile", 16 | "TODO.md" 17 | ] 18 | } -------------------------------------------------------------------------------- /test/mocks/bulk_update_response.mock.coffee: -------------------------------------------------------------------------------- 1 | window.Mocks or= {} 2 | Mocks.bulkUpdateResponse = -> 3 | -> 4 | [ 5 | { 6 | id : "todo/abc3" 7 | ok : true 8 | rev : "1-c7f19547b37274aa672663a5f995c33c" 9 | }, 10 | { 11 | id : "todo/abc2" 12 | error : "conflict" 13 | reason : "Document update conflict." 14 | } 15 | ] -------------------------------------------------------------------------------- /vendor/jquery/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery", 3 | "version": "1.9.1", 4 | "main": "./jquery.js", 5 | "dependencies": {}, 6 | "gitHead": "975556d5d0e4ae21e67a97c0344d5dd89b49ea04", 7 | "_id": "jquery@1.9.1", 8 | "readme": "ERROR: No README.md file found!", 9 | "description": "ERROR: No README.md file found!", 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/components/jquery.git" 13 | } 14 | } -------------------------------------------------------------------------------- /wishlist/InboxOutbox.md: -------------------------------------------------------------------------------- 1 | InboxOutbox 2 | ============= 3 | 4 | The target of InboxOutbox is to allow users make edits to 5 | remote stores that they did not subscribe to and therefore 6 | have no local copies to avoid issues when loosing internet 7 | connection or working offline. 8 | 9 | 1. Caches objects loaded from unsubscribed remotes. 10 | 2. Stores changes to unsubscribed remotes. 11 | 12 | 13 | Idea 14 | ------ 15 | 16 | Could use SessionStorage API. -------------------------------------------------------------------------------- /src/core/errors.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # one place to rule them all! 3 | # 4 | 5 | Hoodie.Errors = 6 | 7 | # ## INVALID_KEY 8 | # 9 | # thrown when invalid keys are used to store an object 10 | # 11 | INVALID_KEY : (idOrType) -> 12 | key = if idOrType.id then 'id' else 'type' 13 | new Error "invalid #{key} '#{idOrType[key]}': numbers and lowercase letters allowed only" 14 | 15 | # ## INVALID_ARGUMENTS 16 | # 17 | INVALID_ARGUMENTS : (msg) -> 18 | new Error msg 19 | 20 | # ## NOT_FOUND 21 | # 22 | NOT_FOUND : (type, id) -> 23 | new Error "#{type} with #{id} could not be found" 24 | -------------------------------------------------------------------------------- /src/extensions/global.coffee: -------------------------------------------------------------------------------- 1 | # Global 2 | # ======== 3 | 4 | # the Global Module provides a simple API to find objects from the global 5 | # stores 6 | # 7 | # For example, the syntax to find all objects from the global store 8 | # looks like this: 9 | # 10 | # hoodie.global.findAll().done( handleObjects ) 11 | # 12 | # okay, might not be the best idea to do that with 1+ million objects, but 13 | # you get the point 14 | # 15 | class Hoodie.Global 16 | 17 | constructor: (hoodie) -> 18 | 19 | # vanilla API syntax: 20 | # hoodie.global.findAll() 21 | `return hoodie.open("global")` 22 | 23 | 24 | # extend Hoodie 25 | Hoodie.extend 'global', Hoodie.Global -------------------------------------------------------------------------------- /test/mocks/changed_docs.mock.coffee: -------------------------------------------------------------------------------- 1 | window.Mocks or= {} 2 | Mocks.changedObjects = -> 3 | [ 4 | { 5 | content : "this is done" 6 | done : true 7 | type : "todo" 8 | id : "abc3" 9 | _rev : '2-123' 10 | _deleted : true 11 | _localInfo: 'funky' 12 | updatedAt: "2012-20-12T12:00:00.000Z" 13 | createdAt: "2012-20-12T12:00:00.000Z" 14 | }, 15 | { 16 | content : "remember the milk" 17 | done : false 18 | type : "todo" 19 | id : "abc2" 20 | updatedAt : "2012-20-12T12:00:00.000Z" 21 | createdAt : "2012-20-12T12:00:00.000Z" 22 | } 23 | ] -------------------------------------------------------------------------------- /vendor/jquery/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "components/jquery", 3 | "description": "jQuery JavaScript Library", 4 | "type": "component", 5 | "homepage": "http://jquery.com", 6 | "license": "MIT", 7 | "support": { 8 | "irc": "irc://irc.freenode.org/jquery", 9 | "issues": "http://bugs.jquery.com", 10 | "forum": "http://forum.jquery.com", 11 | "wiki": "http://docs.jquery.com/", 12 | "source": "https://github.com/jquery/jquery" 13 | }, 14 | "authors": [ 15 | { 16 | "name": "John Resig", 17 | "email": "jeresig@gmail.com" 18 | } 19 | ], 20 | "extra": { 21 | "js": "jquery.js" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /stuff/shortcuts.md: -------------------------------------------------------------------------------- 1 | Shortcuts 2 | =========== 3 | 4 | just some helpers. 5 | 6 | Delete all user accounts 7 | -------------------------- 8 | 9 | ```js 10 | $.couch.db('_users').allDocs( {include_docs: true, success: function(response) { 11 | var docs = [] 12 | $.each(response.rows, function() { 13 | if (/org.couchdb.user/.test(this.id)) docs.push(this.doc) 14 | }) 15 | $.couch.db('_users').bulkRemove({docs: docs}) 16 | }}) 17 | ``` 18 | 19 | Delete all replications 20 | -------------------------- 21 | 22 | ```js 23 | $.couch.db('_replicator').allDocs( {success: function(response) { 24 | var docs = [] 25 | $.each(response.rows, function() { 26 | if (/^[^_]/.test(this.id)) docs.push({_id: this.id, _rev: this.value.rev}) 27 | }) 28 | $.couch.db('_replicator').bulkRemove({docs: docs}) 29 | console.log(docs) 30 | }}) 31 | ``` 32 | 33 | Delete all databases 34 | ---------------------- 35 | 36 | ```js 37 | $.couch.allDbs( {success: function(dbs) { 38 | while( db = dbs.shift() ) { 39 | if ( /^_/.test(db) ) continue; 40 | $.couch.db(db).drop() 41 | } 42 | }} ) 43 | ``` -------------------------------------------------------------------------------- /test/mocks/changes_response.mock.coffee: -------------------------------------------------------------------------------- 1 | window.Mocks or= {} 2 | Mocks.changesResponse = -> 3 | `{ 4 | "results": 5 | [ 6 | { 7 | "seq" :2, 8 | "id" :"todo/abc3", 9 | "changes" :[{"rev":"2-123"}], 10 | "doc" :{"_id":"todo/abc3","_rev":"2-123","_deleted":true}, 11 | "deleted" :true 12 | }, 13 | { 14 | "seq" :3, 15 | "id" :"todo/abc2", 16 | "changes" :[{"rev":"1-123"}], 17 | "doc" :{"_id":"todo/abc2","_rev":"1-123","content":"remember the milk","done":false,"order":1, "type":"todo"} 18 | }, 19 | { 20 | "seq" :4, 21 | "id" :"prefix/todo/abc4", 22 | "changes" :[{"rev":"4-123"}], 23 | "doc" :{"_id":"prefix/todo/abc4","_rev":"4-123","content":"I am prefixed yo.","done":false,"order":2, "type":"todo"} 24 | }, 25 | { 26 | "seq" :5, 27 | "id" :"prefix/todo/abc5", 28 | "changes" :[{"rev":"5-123"}], 29 | "doc" :{"_id":"todo/abc5","_rev":"5-123","content":"deleted, but unknown", "type":"todo","_deleted":true}, 30 | "deleted" :true 31 | } 32 | ], 33 | "last_seq":20 34 | }` -------------------------------------------------------------------------------- /src/core/config.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Central Config API 3 | # 4 | 5 | class Hoodie.Config 6 | 7 | # used as attribute name in localStorage 8 | type : '$config' 9 | id : 'hoodie' 10 | 11 | # ## Constructor 12 | # 13 | constructor : (@hoodie, options = {}) -> 14 | 15 | # memory cache 16 | @cache = {} 17 | 18 | @type = options.type if options.type 19 | @id = options.id if options.id 20 | 21 | @hoodie.store.find(@type, @id).done (obj) => @cache = obj 22 | 23 | @hoodie.on 'account:signedOut', @clear 24 | 25 | 26 | # ## set 27 | # 28 | # adds a configuration 29 | # 30 | set : (key, value) -> 31 | return if @cache[key] is value 32 | 33 | @cache[key] = value 34 | 35 | update = {} 36 | update[key] = value 37 | 38 | isSilent = key.charAt(0) is '_' 39 | @hoodie.store.update @type, @id, update, silent: isSilent 40 | 41 | 42 | # ## get 43 | # 44 | # receives a configuration 45 | # 46 | get : (key) -> 47 | @cache[key] 48 | 49 | 50 | # ## clear 51 | # 52 | # clears cache and removes object from store 53 | clear : => 54 | @cache = {} 55 | @hoodie.store.remove @type, @id 56 | 57 | 58 | # ## remove 59 | # 60 | # removes a configuration, is a simple alias for config.set(key, undefined) 61 | # 62 | remove : (key) -> 63 | @set(key, undefined) -------------------------------------------------------------------------------- /test/lib/phantomjs_console.coffee: -------------------------------------------------------------------------------- 1 | fs = require('fs') 2 | command_file = "/tmp/phantom_command.js" 3 | fs.touch command_file 4 | 5 | url = phantom.args[0] 6 | 7 | unless url 8 | console.log "\nUSAGE:" 9 | console.log "phantomjs path/to/file.html\n" 10 | phantom.exit() 11 | 12 | 13 | 14 | # fs.write user_flags_file, lines.join("\n"), 'w' 15 | 16 | 17 | page = new WebPage() 18 | 19 | page.onConsoleMessage = (msg, line, file)-> 20 | console.log msg 21 | 22 | page.onError = (msg, trace) -> 23 | console.log msg 24 | 25 | page.open phantom.args[0], (status) -> 26 | readCommand = -> 27 | command = fs.read(command_file) 28 | fs.write command_file, '' 29 | if command 30 | # strip the comment on first line 31 | command = command.replace /^.*\n/, '' 32 | 33 | # strip whitespaces 34 | command = command.replace /(^\s+|\s+$)/g, '' 35 | 36 | console.log " > #{command}" 37 | page.evaluate execCommand, command 38 | 39 | execCommand = (command) -> 40 | 41 | ret = eval(command) 42 | console.log "->", ret?.toString().replace(/\n/g, "\n ") unless /console\.log/.test command 43 | 44 | 45 | if status isnt 'success' 46 | console.log status + '! Unable to access ' + phantom.args[0] 47 | phantom.exit() 48 | else 49 | 50 | console.log "" 51 | console.log "Exit with ^ + C" 52 | console.log "" 53 | 54 | setInterval readCommand, 100 -------------------------------------------------------------------------------- /src/core/email.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Sending emails. Not unicorns 3 | # 4 | 5 | class Hoodie.Email 6 | 7 | # ## Constructor 8 | # 9 | constructor : (@hoodie) -> 10 | 11 | # TODO 12 | # let's subscribe to general `_email` changes and provide 13 | # an `on` interface, so devs can listen to events like: 14 | # 15 | # * hoodie.email.on 'sent', -> ... 16 | # * hoodie.email.on 'error', -> ... 17 | 18 | # ## send 19 | # 20 | # sends an email and returns a promise 21 | send : (emailAttributes = {}) -> 22 | defer = @hoodie.defer() 23 | attributes = $.extend {}, emailAttributes 24 | 25 | unless @_isValidEmail emailAttributes.to 26 | attributes.error = "Invalid email address (#{attributes.to or 'empty'})" 27 | return defer.reject(attributes).promise() 28 | 29 | @hoodie.store.add('$email', attributes).then (obj) => 30 | @_handleEmailUpdate(defer, obj) 31 | 32 | defer.promise() 33 | 34 | # ## PRIVATE 35 | # 36 | _isValidEmail : (email = '') -> 37 | /@/.test email 38 | 39 | _handleEmailUpdate : (defer, attributes = {}) => 40 | if attributes.error 41 | defer.reject attributes 42 | else if attributes.deliveredAt 43 | defer.resolve attributes 44 | else 45 | @hoodie.remote.one "updated:$email:#{attributes.id}", (attributes) => @_handleEmailUpdate(defer, attributes) 46 | 47 | 48 | 49 | # extend Hoodie 50 | Hoodie.extend 'email', Hoodie.Email -------------------------------------------------------------------------------- /stuff/backbone-hoodie.coffee: -------------------------------------------------------------------------------- 1 | Backbone.connect = (url) -> 2 | Backbone.hoodie = new Hoodie url 3 | 4 | Backbone.sync = (method, modelOrCollection, options) -> 5 | {id, attributes, type} = modelOrCollection 6 | type or= modelOrCollection.model::type 7 | 8 | promise = switch method 9 | when "read" 10 | if id 11 | Backbone.hoodie.store.find(type, id) 12 | else 13 | Backbone.hoodie.store.findAll() 14 | 15 | when "create" 16 | Backbone.hoodie.store.create(type, attributes) 17 | 18 | when "update" 19 | Backbone.hoodie.store.update(type, id, attributes) 20 | 21 | when "delete" 22 | Backbone.hoodie.store.delete(type, id) 23 | 24 | promise.done options.success if options.success 25 | promise.fail options.error if options.error 26 | 27 | # simple merge strategy: remote always wins. 28 | # Feel free to overwrite. 29 | Backbone.Model::merge = (attributes) -> 30 | @set attributes, remote: true 31 | 32 | # Make Collections listen to remote events. 33 | Backbone.Collection::initialize = -> 34 | type = @model::type 35 | opts = remote: true 36 | 37 | if @model::type 38 | Backbone.hoodie.remote.on "create:#{@model::type}", (id, attributes) => @add attributes, opts 39 | Backbone.hoodie.remote.on "destroye:#{@model::type}", (id, attributes) => @get(id)?.destroy opts 40 | Backbone.hoodie.remote.on "update:#{@model::type}", (id, attributes) => @get(id)?.merge attributes, opts -------------------------------------------------------------------------------- /wishlist/open.js: -------------------------------------------------------------------------------- 1 | // hoodie.open 2 | // ============= 3 | // 4 | // just some loose thoughts on a hoodie.open method. 5 | 6 | // open a "store" 7 | hoodie.open("user/joe") 8 | hoodie.open("user/jane/public").store.findAll( function(objects) {}) 9 | hoodie.open("share/abc8320", {password: "secret"}).subscribe() 10 | hoodie.open("global").on("store:created:track", function(track) {}) 11 | 12 | // shortcuts 13 | hoodie.remote.push() 14 | hoodie.user('jane').store.findAll( function(objects) {}) 15 | hoodie.share('abc832', {password: "secret"}).subscribe() 16 | hoodie.global.on("store:created:track", function(track) {}) 17 | 18 | 19 | // ## a "store" module? 20 | // 21 | // I can open any kind of named store, like a sharing or a users public 22 | // store. An "opened" store does always provide the same API whereat 23 | // some might require special privileges. They all return a promise 24 | 25 | // instantiate 26 | share = hoodie("share/abc8320") 27 | 28 | // store / find objects 29 | share.store.find("todolist","xy20ad9") 30 | share.store.findAll("todo") 31 | share.store.create("todo", {name: "remember the milk"}) 32 | share.store.save("todo", "exists7", {name: "get some rest"}) 33 | share.store.update("todo", "exists7", {name: "get some rest"}) 34 | share.store.updateAll("todo", {done: true}) 35 | share.store.remove("todo", "exists7") 36 | share.store.removeAll("todo") 37 | share.store.get("completed_todos") 38 | share.store.post("notify", {"email": "jane@xmpl.com"}) 39 | 40 | // sync 41 | share.connect() 42 | share.disconnect() 43 | share.pull() 44 | share.push() 45 | share.sync() 46 | 47 | // event binding 48 | share.on("event", callback) 49 | 50 | 51 | // ## options 52 | 53 | // password 54 | hoodie.open("share/abc8320", { 55 | password: "secret" 56 | }) -------------------------------------------------------------------------------- /stuff/spine-hoodie.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Spine ♥ Hoodie 3 | # 4 | # To use Hoodie as a Store for spine.js apps, require `spine-hoodie.coffee` in 5 | # you bootstrap file and then extend your Models usin `Spine.Model.Hoodie` 6 | # 7 | # Hoodie = require('lib/spine-hoodie') 8 | # Spine.hoodie = new Hoodie(config.hoodie_url) 9 | # 10 | # class Car extends Spine.Model 11 | # @configure 'Image', 'color' 12 | # @extend Spine.Model.Hoodie 13 | # 14 | 15 | Spine.Model.Hoodie = 16 | 17 | # extend the Model 18 | extended: -> 19 | 20 | # add type to attributes. 21 | # Turns `@configure 'Image', 'color'` into `@configure 'Image', 'type', 'color'` 22 | type = @className.toLowerCase() 23 | @attributes.unshift 'type' 24 | 25 | # hook into record events 26 | @change (object, event, data) => 27 | switch event 28 | when 'create' 29 | Spine.hoodie.store.create type, object.toJSON() 30 | when 'update' 31 | Spine.hoodie.store.update type, object.id, object.toJSON() 32 | when 'destroy' 33 | Spine.hoodie.store.destroy type, object.id 34 | 35 | # fetch records from hoodie.store 36 | @fetch => 37 | Spine.hoodie.store.findAll(type) 38 | .done (records) => @refresh(records) 39 | 40 | # listen to remote events on records 41 | Spine.hoodie.remote.on "change:#{type}", (event, remoteObject) => 42 | switch event 43 | when 'create' 44 | @refresh remoteObject 45 | when 'destroye' 46 | @destroy remoteObject.id 47 | when 'update' 48 | localObject = @find(remoteObject.id) 49 | for attr, value of remoteObject 50 | localObject[attr] = value 51 | localObject.save() 52 | 53 | module?.exports = Hoodie -------------------------------------------------------------------------------- /test/specs/core/config.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "Hoodie.Config", -> 2 | beforeEach -> 3 | @hoodie = new Mocks.Hoodie 4 | @config = new Hoodie.Config @hoodie 5 | 6 | describe "constructor(@hoodie, options)", -> 7 | it "should default @type to '$config'", -> 8 | config = new Hoodie.Config @hoodie 9 | expect(config.type).toBe '$config' 10 | 11 | it "should default @id to 'hoodie'", -> 12 | config = new Hoodie.Config @hoodie 13 | expect(config.id).toBe 'hoodie' 14 | # /.constructor(@hoodie, options) 15 | 16 | describe "#set(key, value)", -> 17 | beforeEach -> 18 | spyOn(@hoodie.store, "update") 19 | 20 | it "should save a $config with key: value", -> 21 | @config.set('funky', 'fresh') 22 | expect(@hoodie.store.update).wasCalledWith '$config', 'hoodie', {funky: 'fresh'}, silent: false 23 | 24 | it "should make the save silent for local settings starting with _", -> 25 | @config.set('_local', 'fresh') 26 | expect(@hoodie.store.update).wasCalledWith '$config', 'hoodie', {_local: 'fresh'}, silent: true 27 | 28 | # /.set(key, value) 29 | 30 | describe "#get(key)", -> 31 | beforeEach -> 32 | spyOn(@hoodie.store, "find").andReturn @hoodie.defer().resolve funky: 'fresh' 33 | @config = new Hoodie.Config @hoodie 34 | 35 | it "should get the config using store", -> 36 | expect(@config.get('funky')).toBe 'fresh' 37 | # /.get(key) 38 | 39 | describe "#remove(key)", -> 40 | beforeEach -> 41 | spyOn(@hoodie.store, "update").andReturn 'promise' 42 | 43 | it "should remove the config using store", -> 44 | @config.set('funky', 'fresh') 45 | @config.remove('funky') 46 | expect(@hoodie.store.update).wasCalledWith '$config', 'hoodie', {funky: undefined}, silent: false 47 | # /.remove(key) -------------------------------------------------------------------------------- /src/extensions/user.coffee: -------------------------------------------------------------------------------- 1 | # User 2 | # ====== 3 | 4 | # the User Module provides a simple API to find objects from other users public 5 | # stores 6 | # 7 | # For example, the syntax to find all objects from user "Joe" looks like this: 8 | # 9 | # hoodie.user("Joe").findAll().done( handleObjects ) 10 | # 11 | class Hoodie.User 12 | 13 | constructor: (@hoodie) -> 14 | 15 | # extend hodie.store promise API 16 | @hoodie.store.decoratePromises 17 | publish : @_storePublish 18 | unpublish : @_storeUnpublish 19 | 20 | # vanilla API syntax: 21 | # hoodie.user('uuid1234').findAll() 22 | `return this.api` 23 | 24 | # 25 | api : (userHash, options = {}) => 26 | $.extend options, prefix: '$public' 27 | @hoodie.open "user/#{userHash}/public", options 28 | 29 | 30 | 31 | # hoodie.store decorations 32 | # -------------------------- 33 | # 34 | # hoodie.store decorations add custom methods to promises returned 35 | # by hoodie.store methods like find, add or update. All methods return 36 | # methods again that will be executed in the scope of the promise, but 37 | # with access to the current hoodie instance 38 | 39 | # publish 40 | # 41 | # publish an object. If an array of properties passed, publish only these 42 | # attributes and hide the remaining ones. If no properties passed, publish 43 | # the entire object. 44 | _storePublish : (properties) -> 45 | @pipe (objects) => 46 | objects = [objects] unless $.isArray objects 47 | for object in objects 48 | @hoodie.store.update object.type, object.id, $public: properties or true 49 | 50 | # `unpublish` 51 | # 52 | # unpublish 53 | _storeUnpublish : -> 54 | @pipe (objects) => 55 | objects = [objects] unless $.isArray objects 56 | for object in objects when object.$public 57 | @hoodie.store.update object.type, object.id, $public: false 58 | 59 | 60 | # extend Hoodie 61 | Hoodie.extend 'user', Hoodie.User -------------------------------------------------------------------------------- /vendor/prism/prism.css: -------------------------------------------------------------------------------- 1 | /** 2 | * prism.js default theme for JavaScript, CSS and HTML 3 | * Based on dabblet (http://dabblet.com) 4 | * @author Lea Verou 5 | */ 6 | 7 | code[class*="language-"], 8 | pre[class*="language-"] { 9 | color: black; 10 | text-shadow: 0 1px white; 11 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 12 | direction: ltr; 13 | text-align: left; 14 | white-space: pre; 15 | word-spacing: normal; 16 | 17 | -moz-tab-size: 4; 18 | -o-tab-size: 4; 19 | tab-size: 4; 20 | 21 | -webkit-hyphens: none; 22 | -moz-hyphens: none; 23 | -ms-hyphens: none; 24 | hyphens: none; 25 | } 26 | 27 | @media print { 28 | code[class*="language-"], 29 | pre[class*="language-"] { 30 | text-shadow: none; 31 | } 32 | } 33 | 34 | /* Code blocks */ 35 | pre[class*="language-"] { 36 | padding: 1em; 37 | margin: .5em 0; 38 | overflow: auto; 39 | } 40 | 41 | :not(pre) > code[class*="language-"], 42 | pre[class*="language-"] { 43 | background: #f5f2f0; 44 | } 45 | 46 | /* Inline code */ 47 | :not(pre) > code[class*="language-"] { 48 | padding: .1em; 49 | border-radius: .3em; 50 | } 51 | 52 | .token.comment, 53 | .token.prolog, 54 | .token.doctype, 55 | .token.cdata { 56 | color: slategray; 57 | } 58 | 59 | .token.punctuation { 60 | color: #999; 61 | } 62 | 63 | .namespace { 64 | opacity: .7; 65 | } 66 | 67 | .token.property, 68 | .token.tag, 69 | .token.boolean, 70 | .token.number { 71 | color: #905; 72 | } 73 | 74 | .token.selector, 75 | .token.attr-name, 76 | .token.string { 77 | color: #690; 78 | } 79 | 80 | .token.operator, 81 | .token.entity, 82 | .token.url, 83 | .language-css .token.string, 84 | .style .token.string { 85 | color: #a67f59; 86 | background: hsla(0,0%,100%,.5); 87 | } 88 | 89 | .token.atrule, 90 | .token.attr-value, 91 | .token.keyword { 92 | color: #07a; 93 | } 94 | 95 | 96 | .token.regex, 97 | .token.important { 98 | color: #e90; 99 | } 100 | 101 | .token.important { 102 | font-weight: bold; 103 | } 104 | 105 | .token.entity { 106 | cursor: help; 107 | } 108 | -------------------------------------------------------------------------------- /test/mocks/hoodie.mock.coffee: -------------------------------------------------------------------------------- 1 | window.Mocks or= {} 2 | 3 | promiseMock = 4 | pipe : -> 5 | fail : -> 6 | done : -> 7 | then : -> 8 | 9 | Mocks.Hoodie = -> 10 | 11 | baseUrl : 'http://my.cou.ch' 12 | 13 | trigger : Hoodie::trigger 14 | request : -> 15 | checkConnection : -> 16 | open : -> 17 | on : Hoodie::on 18 | one : Hoodie::one 19 | unbind : Hoodie::unbind 20 | defer : $.Deferred 21 | isPromise : Hoodie::isPromise 22 | uuid : -> 'uuid' 23 | resolveWith : -> $.Deferred().resolve(arguments...).promise() 24 | rejectWith : -> $.Deferred().reject(arguments...).promise() 25 | 26 | store : 27 | add : -> promiseMock 28 | remove : -> promiseMock 29 | save : -> promiseMock 30 | update : -> promiseMock 31 | updateAll : -> promiseMock 32 | find : -> promiseMock 33 | findAll : -> promiseMock 34 | findOrAdd : -> promiseMock 35 | removeAll : -> promiseMock 36 | changedObjects : -> 37 | isDirty : -> 38 | decoratePromises: -> 39 | 40 | db : 41 | getItem : -> 42 | setItem : -> 43 | removeItem : -> 44 | 45 | account : 46 | authenticate : -> promiseMock 47 | db : -> 48 | on : -> 49 | ownerHash : 'owner_hash' 50 | hasAccount : -> 51 | anonymousSignUp : -> 52 | 53 | config : 54 | set : -> 55 | get : -> 56 | remove : -> 57 | clear : -> 58 | 59 | remote : 60 | connect : -> 61 | disconnect : -> 62 | sync : -> 63 | on : -> 64 | one : -> 65 | trigger : -> 66 | 67 | share : 68 | add : -> promiseMock 69 | remove : -> promiseMock 70 | save : -> promiseMock 71 | update : -> promiseMock 72 | updateAll : -> promiseMock 73 | find : -> promiseMock 74 | findAll : -> promiseMock 75 | findOrAdd : -> promiseMock 76 | removeAll : -> promiseMock 77 | request : -> promiseMock 78 | -------------------------------------------------------------------------------- /src/events.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Events 3 | # ------ 4 | # 5 | # extend any Class with support for 6 | # 7 | # * `object.bind('event', cb)` 8 | # * `object.unbind('event', cb)` 9 | # * `object.trigger('event', args...)` 10 | # * `object.one('ev', cb)` 11 | # 12 | # based on [Events implementations from Spine](https://github.com/maccman/spine/blob/master/src/spine.coffee#L1) 13 | # 14 | 15 | class Events 16 | 17 | # ## Bind 18 | # 19 | # bind a callback to an event triggerd by the object 20 | # 21 | # object.bind 'cheat', blame 22 | # 23 | bind: (ev, callback) -> 24 | evs = ev.split(' ') 25 | calls = @hasOwnProperty('_callbacks') and @_callbacks or= {} 26 | 27 | for name in evs 28 | calls[name] or= [] 29 | calls[name].push(callback) 30 | 31 | # alias 32 | on: @::bind 33 | 34 | # ## one 35 | # 36 | # same as `bind`, but does get executed only once 37 | # 38 | # object.one 'groundTouch', gameOver 39 | one: (ev, callback) -> 40 | @bind ev, -> 41 | @unbind(ev, arguments.callee) 42 | callback.apply(@, arguments) 43 | 44 | 45 | # ## trigger 46 | # 47 | # trigger an event and pass optional parameters for binding. 48 | # 49 | # object.trigger 'win', score: 1230 50 | trigger: (args...) -> 51 | ev = args.shift() 52 | 53 | list = @hasOwnProperty('_callbacks') and @_callbacks?[ev] 54 | return unless list 55 | 56 | callback.apply(@, args) for callback in list 57 | 58 | return true 59 | 60 | 61 | # ## unbind 62 | # 63 | # unbind to from all bindings, from all bindings of a specific event 64 | # or from a specific binding. 65 | # 66 | # object.unbind() 67 | # object.unbind 'move' 68 | # object.unbind 'move', follow 69 | # 70 | unbind: (ev, callback) -> 71 | unless ev 72 | @_callbacks = {} 73 | return this 74 | 75 | list = @_callbacks?[ev] 76 | return this unless list 77 | 78 | unless callback 79 | delete @_callbacks[ev] 80 | return this 81 | 82 | for cb, i in list when cb is callback 83 | list = list.slice() 84 | list.splice(i, 1) 85 | @_callbacks[ev] = list 86 | break 87 | 88 | return this -------------------------------------------------------------------------------- /test/specs/events.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "Events", -> 2 | beforeEach -> 3 | @obj = new Events 4 | 5 | describe ".bind(event, callback)", -> 6 | it "should bind the passed callback to the passed event", -> 7 | cb = jasmine.createSpy 'test' 8 | @obj.bind 'test', cb 9 | @obj.trigger 'test' 10 | expect(cb).wasCalled() 11 | 12 | it "should allow to pass multiple events", -> 13 | cb = jasmine.createSpy 'test' 14 | @obj.bind 'test1 test2', cb 15 | @obj.trigger 'test1' 16 | @obj.trigger 'test2' 17 | expect(cb.callCount).toBe 2 18 | # /.bind(event, callback) 19 | 20 | describe ".one(event, callback)", -> 21 | it "should bind passed callback to first occurence of passed event", -> 22 | cb = jasmine.createSpy 'test' 23 | @obj.one 'test', cb 24 | @obj.trigger 'test' 25 | @obj.trigger 'test' 26 | expect(cb.callCount).toBe 1 27 | # /.one(event, callback) 28 | 29 | describe ".trigger(event, args...)", -> 30 | it "should call subscribed callbacks", -> 31 | cb1 = jasmine.createSpy 'test1' 32 | cb2 = jasmine.createSpy 'test2' 33 | @obj.bind 'test', cb1 34 | @obj.bind 'test', cb2 35 | @obj.trigger 'test' 36 | expect(cb1).wasCalled() 37 | expect(cb2).wasCalled() 38 | 39 | it "should pass arguments", -> 40 | cb = jasmine.createSpy 'test' 41 | @obj.bind 'test', cb 42 | @obj.trigger 'test', 'arg1', 'arg2', 'arg3' 43 | expect(cb).wasCalledWith 'arg1', 'arg2', 'arg3' 44 | # /.trigger(event, args...) 45 | 46 | describe ".unbind(event, callback)", -> 47 | _when "callback passed", -> 48 | it "should unsubscribe the callback", -> 49 | cb = jasmine.createSpy 'test' 50 | @obj.bind 'test', cb 51 | @obj.unbind 'test', cb 52 | @obj.trigger 'test' 53 | expect(cb).wasNotCalled() 54 | 55 | _when "no callback passed", -> 56 | it "should unsubscribe all callbacks", -> 57 | cb1 = jasmine.createSpy 'test1' 58 | cb2 = jasmine.createSpy 'test2' 59 | @obj.bind 'test', cb1 60 | @obj.bind 'test', cb2 61 | @obj.unbind 'test' 62 | @obj.trigger 'test' 63 | expect(cb1).wasNotCalled() 64 | expect(cb2).wasNotCalled() 65 | # /.unbind(event, callback) -------------------------------------------------------------------------------- /doc/extensions/global.html: -------------------------------------------------------------------------------- 1 | extensions/global
src/extensions/global.coffee

Global

the Global Module provides a simple API to find objects from the global 2 | stores

#

For example, the syntax to find all objects from the global store 3 | looks like this:

#
hoodie.global.findAll().done( handleObjects )
4 | 
#

okay, might not be the best idea to do that with 1+ million objects, but 5 | you get the point

# 6 | class Hoodie.Global 7 | 8 | constructor: (hoodie) ->

vanilla API syntax: 9 | hoodie.global.findAll()

`return hoodie.open("global")`

extend Hoodie

Hoodie.extend 'global', Hoodie.Global
-------------------------------------------------------------------------------- /quickstart_for_developers.md: -------------------------------------------------------------------------------- 1 | Quickstart for developers 2 | ========================= 3 | 4 | hoodie.js is written in [CoffeeScript](http://coffeescript.org/) and uses [phantomJS](http://phantomjs.org/) for automated, headless testing. 5 | 6 | ```bash 7 | # install CoffeScript 8 | npm install -g coffee-script 9 | # install phantomJS for testing 10 | brew update && brew install phantomjs 11 | ``` 12 | 13 | That's all you need. Make your changes, run the test, send a pull request, win karma. We've lots to give 14 | 15 | Here are your friendly [cake](http://coffeescript.org/documentation/docs/cake.html) helpers 16 | 17 | ```bash 18 | cake compile # Build lib/ 19 | cake watch # Build lib/ and watch for changes 20 | cake test # Run all test 21 | cake autotest # Run all tests & rerun on file changes 22 | cake console # run a browser console, from command line, hell yeah 23 | cake build # build hoodie.min.js 24 | cake docs # create docs from code 25 | cake wishlist # create docs from dream code 26 | cake all # one cake to rule them all 27 | ``` 28 | 29 | 30 | hoodie backend (server) 31 | ----------------------- 32 | 33 | If you want to run a hoodie server locally, you need [hoodie app](https://github.com/hoodiehq/hoodie-app). 34 | 35 | The hoodie server is a couchDB instance with some workers listening to changes and doing things like 36 | creating databases for users or sending emails. `hoodie.js` is talking directly with the couchDB api. 37 | 38 | Here is a list of requests that hoodie.js is sending: 39 | 40 | * POST, DELETE /_session 41 | * GET, PUT, DELETE /_users/username 42 | * GET /user_database/_changes 43 | * POST /user_database/_bulk_docs 44 | * GET, PUT, DELETE /user_database/id 45 | 46 | not yet, but probably soon 47 | 48 | * GET /user_database/_design/doc/_view/name 49 | * PUT /user_database/_design/doc/_update/name/id 50 | 51 | 52 | Q & A 53 | ----- 54 | 55 | ### 1. I HATE CoffeeScript 56 | 57 | [I Wrote This Song for You](http://youtu.be/yMs712oA_Lg) 58 | 59 | ### 2. I don't really like CoffeeScript 60 | 61 | Oh, that's actually great! What we care most about is [hoodie's API](http://hoodiehq.github.com/hoodie.js). 62 | If you feel the implemantion could be better, please go ahead, we're happy to assist. Take [underscore](http://underscorejs.org/) / 63 | [lodash](http://lodash.com/) for a great outcome of the same approach. 64 | 65 | ### 3. I don't like nodeJS / couchDB / ponys? 66 | 67 | Oh my, even better! We think every backend deserve a nicely tailored hoodie, wouldn't you agree? 68 | So why not make one for your beloved one, we are happy to help. Just stick to [hoodie's API](http://hoodiehq.github.com/hoodie.js) 69 | and the frotend-ers out there won't even tell the difference ;-) -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jasmine Test Runner 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 53 | 54 | 55 | 60 | 61 | -------------------------------------------------------------------------------- /test/lib/jasmine-helpers.coffee: -------------------------------------------------------------------------------- 1 | _when = (description, specs) -> describe("when " + description, specs) 2 | _and = (description, specs) -> describe("and " + description, specs) 3 | _but = (description, specs) -> describe("but " + description, specs) 4 | 5 | jasmine.Matchers.prototype.toBePromise = -> 6 | this.actual.done and !this.actual.resolve 7 | 8 | jasmine.Matchers.prototype.toBeDefer = -> 9 | this.actual.done and this.actual.resolve 10 | 11 | 12 | 13 | jasmine.Matchers.prototype.toBeRejected = -> this.actual.state() is 'rejected' 14 | jasmine.Matchers.prototype.toBeResolved = -> this.actual.state() is 'resolved' 15 | jasmine.Matchers.prototype.notToBeRejected = -> this.actual.state() isnt 'rejected' 16 | jasmine.Matchers.prototype.notToBeResolved = -> this.actual.state() isnt 'resolved' 17 | 18 | jasmine.Matchers.prototype.toBeResolvedWith = -> 19 | expectedArgs = jasmine.util.argsToArray(arguments); 20 | 21 | unless this.actual.done 22 | throw new Error('Expected a promise, but got ' + jasmine.pp(this.actual) + '.'); 23 | 24 | done = jasmine.createSpy 'done' 25 | this.actual.done done 26 | 27 | this.message = -> 28 | if done.callCount == 0 29 | return [ 30 | "Expected spy " + done.identity + " to have been resolved with " + jasmine.pp(expectedArgs) + " but it was never resolved.", 31 | "Expected spy " + done.identity + " not to have been resolved with " + jasmine.pp(expectedArgs) + " but it was." 32 | ]; 33 | else 34 | return [ 35 | "Expected spy " + done.identity + " to have been resolved with " + jasmine.pp(expectedArgs) + " but was resolved with " + jasmine.pp(done.argsForCall), 36 | "Expected spy " + done.identity + " not to have been resolved with " + jasmine.pp(expectedArgs) + " but was resolved with " + jasmine.pp(done.argsForCall) 37 | ]; 38 | 39 | return this.env.contains_(done.argsForCall, expectedArgs); 40 | 41 | jasmine.Matchers.prototype.toBeRejectedWith = -> 42 | expectedArgs = jasmine.util.argsToArray(arguments); 43 | 44 | unless this.actual.fail 45 | throw new Error('Expected a promise, but got ' + jasmine.pp(this.actual) + '.'); 46 | 47 | fail = jasmine.createSpy 'fail' 48 | this.actual.fail fail 49 | 50 | this.message = -> 51 | if fail.callCount == 0 52 | return [ 53 | "Expected spy " + fail.identity + " to have been rejected with " + jasmine.pp(expectedArgs) + " but it was never rejected.", 54 | "Expected spy " + fail.identity + " not to have been rejected with " + jasmine.pp(expectedArgs) + " but it was." 55 | ]; 56 | else 57 | return [ 58 | "Expected spy " + fail.identity + " to have been rejected with " + jasmine.pp(expectedArgs) + " but was rejected with " + jasmine.pp(fail.argsForCall), 59 | "Expected spy " + fail.identity + " not to have been rejected with " + jasmine.pp(expectedArgs) + " but was rejected with " + jasmine.pp(fail.argsForCall) 60 | ]; 61 | 62 | return this.env.contains_(fail.argsForCall, expectedArgs); 63 | -------------------------------------------------------------------------------- /src/core/account_remote.coffee: -------------------------------------------------------------------------------- 1 | # AccountRemote 2 | # =============== 3 | 4 | # Connection / Socket to our couch 5 | # 6 | # AccountRemote is using CouchDB's `_changes` feed to 7 | # listen to changes and `_bulk_docs` to push local changes 8 | # 9 | # When hoodie.remote is continuously syncing (default), 10 | # it will continuously synchronize with local store, 11 | # otherwise sync, pull or push can be called manually 12 | # 13 | class Hoodie.AccountRemote extends Hoodie.Remote 14 | 15 | # properties 16 | # ------------ 17 | 18 | # connect by default 19 | connected: true 20 | 21 | 22 | # Constructor 23 | # ------------- 24 | 25 | # 26 | constructor : (@hoodie, options = {}) -> 27 | # set name to user's DB name 28 | @name = @hoodie.account.db() 29 | 30 | # we're always connected to our own db 31 | @connected = true 32 | 33 | # do not prefix files for my own remote 34 | options.prefix = '' 35 | 36 | @hoodie.on 'account:authenticated', @_handleAuthenticate 37 | @hoodie.on 'account:signout', @disconnect 38 | @hoodie.on 'reconnected', @connect 39 | 40 | super(@hoodie, options) 41 | 42 | 43 | # Connect 44 | # --------- 45 | 46 | # do not start to connect immediately, but authenticate beforehand 47 | # 48 | connect : => 49 | @hoodie.account.authenticate().pipe => 50 | @hoodie.on 'store:idle', @push 51 | @push() 52 | super 53 | 54 | 55 | # disconnect 56 | # ------------ 57 | 58 | # 59 | disconnect: => 60 | @hoodie.unbind 'store:idle', @push 61 | super 62 | 63 | 64 | # get and set since nr 65 | # ---------------------- 66 | 67 | # we store the last since number from the current user's store 68 | # in his config 69 | # 70 | getSinceNr : (since) -> 71 | @hoodie.config.get('_remote.since') or 0 72 | setSinceNr : (since) -> 73 | @hoodie.config.set('_remote.since', since) 74 | 75 | 76 | # push 77 | # ------ 78 | 79 | # if no objects passed to be pushed, we default to 80 | # changed objects in user's local store 81 | push : (objects) => 82 | unless @isConnected() 83 | error = new ConnectionError("Not connected: could not push local changes to remote") 84 | return @hoodie.rejectWith error 85 | objects = @hoodie.store.changedObjects() unless $.isArray objects 86 | 87 | promise = super(objects) 88 | promise.fail @hoodie.checkConnection 89 | return promise 90 | 91 | 92 | # Events 93 | # -------- 94 | 95 | # namespaced alias for `hoodie.on` 96 | # 97 | on : (event, cb) -> 98 | event = event.replace /(^| )([^ ]+)/g, "$1remote:$2" 99 | @hoodie.on event, cb 100 | one : (event, cb) -> 101 | event = event.replace /(^| )([^ ]+)/g, "$1remote:$2" 102 | @hoodie.one event, cb 103 | 104 | # namespaced alias for `hoodie.trigger` 105 | # 106 | trigger : (event, parameters...) -> 107 | @hoodie.trigger "remote:#{event}", parameters... 108 | 109 | 110 | # Private 111 | # --------- 112 | 113 | # 114 | _handleAuthenticate : => 115 | @name = @hoodie.account.db() 116 | @connect() -------------------------------------------------------------------------------- /doc/core/errors.html: -------------------------------------------------------------------------------- 1 | core/errors
src/core/errors.coffee
#

one place to rule them all!

# 2 | 3 | Hoodie.Errors = 4 |

INVALID_KEY

#

thrown when invalid keys are used to store an object

# 5 | INVALID_KEY : (idOrType) -> 6 | key = if idOrType.id then 'id' else 'type' 7 | new Error "invalid #{key} '#{idOrType[key]}': numbers and lowercase letters allowed only"

INVALID_ARGUMENTS

# 8 | INVALID_ARGUMENTS : (msg) -> 9 | new Error msg

NOT_FOUND

# 10 | NOT_FOUND : (type, id) -> 11 | new Error "#{type} with #{id} could not be found"
-------------------------------------------------------------------------------- /test/specs/core/email.spec.coffee: -------------------------------------------------------------------------------- 1 | # ## ref success 2 | # { 3 | # to: "jin@beam.org", 4 | # subject: "Tolle Liste", 5 | # body: "...", 6 | # deliveredAt: "2012-05-05 15:00 UTC" 7 | # } 8 | 9 | # ## ref error 10 | # { 11 | # to: "jin@beam.org", 12 | # subject: "Tolle Liste", 13 | # body: "...", 14 | # error: "No such recipient" 15 | # } 16 | 17 | describe "Hoodie.Email", -> 18 | beforeEach -> 19 | @hoodie = new Mocks.Hoodie 20 | @email = new Hoodie.Email @hoodie 21 | 22 | @errorSpy = jasmine.createSpy 'error' 23 | @successSpy = jasmine.createSpy 'success' 24 | 25 | describe ".send(emailAttributes)", -> 26 | beforeEach -> 27 | @emailAttributes = 28 | to : 'jim@be.am' 29 | subject : 'subject' 30 | body : 'body' 31 | (spyOn @hoodie.store, "add").andReturn 32 | then: (cb) -> cb $.extend {}, @emailAttributes, id: 'abc4567' 33 | 34 | it "should reject the promise", -> 35 | expect( @email.send(@emailAttributes) ).toBePromise() 36 | 37 | it "should save the email as object with type: $email", -> 38 | @email.send(@emailAttributes) 39 | (expect @hoodie.store.add).wasCalledWith('$email', @emailAttributes) 40 | 41 | it "should listen to server response", -> 42 | spyOn @hoodie.remote, "one" 43 | @email.send(@emailAttributes) 44 | expect(@hoodie.remote.one).wasCalled() 45 | expect(@hoodie.remote.one.mostRecentCall.args[0]).toEqual "updated:$email:abc4567" 46 | 47 | _when "email.to is not provided", -> 48 | beforeEach -> 49 | @emailAttributes.to = '' 50 | 51 | it "should reject the promise", -> 52 | promise = @email.send(@emailAttributes) 53 | promise.fail @errorSpy 54 | (expect @errorSpy).wasCalledWith($.extend @emailAttributes, {error: 'Invalid email address (empty)'}) 55 | 56 | _when "email.to is 'invalid'", -> 57 | beforeEach -> 58 | @emailAttributes.to = 'invalid' 59 | 60 | it "should reject the promise", -> 61 | promise = @email.send(@emailAttributes) 62 | promise.fail @errorSpy 63 | (expect @errorSpy).wasCalledWith($.extend @emailAttributes, {error: 'Invalid email address (invalid)'}) 64 | 65 | _when "sending email was successful", -> 66 | beforeEach -> 67 | @emailResponseAttributes = $.extend {}, @emailAttributes, id: 'abc4567', deliveredAt: "2012-05-05 15:00 UTC" 68 | (spyOn @hoodie.remote, "one").andCallFake (event, cb) => 69 | cb @emailResponseAttributes 70 | @promise = @email.send(@emailAttributes) 71 | 72 | it "should resolve the promise", -> 73 | @promise.done @successSpy 74 | (expect @successSpy).wasCalledWith @emailResponseAttributes 75 | 76 | _when "sending email had an error", -> 77 | beforeEach -> 78 | @emailResponseAttributes = $.extend {}, @emailAttributes, id: 'abc4567', error: "U SPAM!" 79 | (spyOn @hoodie.remote, "one").andCallFake (event, cb) => 80 | cb @emailResponseAttributes 81 | @promise = @email.send(@emailAttributes) 82 | 83 | it "should resolve the promise", -> 84 | @promise.fail @errorSpy 85 | (expect @errorSpy).wasCalledWith @emailResponseAttributes 86 | # /.send(email) 87 | # /Hoodie.Email -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/hoodiehq/hoodie.js.png?branch=master)](https://travis-ci.org/hoodiehq/hoodie.js) 2 | 3 | hoodie ✪ power to the frontend! 4 | =============================== 5 | 6 | hoodie is a JavaScript library that runs in your browser. 7 | It gives you 8 | 9 | * user authentication 10 | * data storage and sync 11 | * sharing 12 | * emails 13 | * and so much more 14 | 15 | And this is what it looks like: 16 | 17 | ```javascript 18 | // user authentication & more 19 | hoodie.account.signUp('joe@example.com', 'secret') 20 | hoodie.account.signIn('joe@example.com', 'secret') 21 | hoodie.account.changePassword('secret', 'new_secret') 22 | hoodie.account.changeUsername('secret', 'newusername') 23 | hoodie.account.signOut() 24 | hoodie.account.resetPassword('joe@example.com') 25 | 26 | // store data (it will sync to whereever your users sign in) 27 | hoodie.store.add('task', {title: 'build sweetMasterApp tomorrow.'}) 28 | hoodie.store.findAll('task') 29 | hoodie.store.update('task', '123', {done: true}) 30 | hoodie.store.remove('task', '123') 31 | 32 | hoodie.store.on('add:task', function(object) { 33 | alert('new Task added: ' + object.task) 34 | }) 35 | 36 | // publish & share data 37 | hoodie.store.findAll('task').publish() 38 | hoodie.user( username ).findAll() 39 | 40 | hoodie.store.find('task', '456').share() 41 | hoodie.share(shareId).findAll() 42 | hoodie.share(shareId).subscribe() 43 | 44 | // sending emails … yep. 45 | var magic = hoodie.email.send({ 46 | to : ['susan@example.com'], 47 | cc : ['bill@example.com'], 48 | subject : 'rule the world', 49 | body : 'we can do it!\nSigned, Joe' 50 | }) 51 | magic.done( function(mail) { 52 | alert('Mail has been sent to ' + mail.to) 53 | }) 54 | magic.fail( function(eror) { 55 | alert('Sory, but something went wrong: ' + error.reason) 56 | }) 57 | 58 | // Like what you see? Good. Because we got more: 59 | // http://hoodiehq.github.com/hoodie.js 60 | // API DOCS: http://hoodiehq.github.com/hoodie.js/doc/hoodie.html 61 | ``` 62 | 63 | 64 | But … how does it work? 65 | ----------------------- 66 | 67 | It's magic, stupid!™ 68 | 69 | Every app gets its own hoodie. You need to set one up, because that's `whereTheMagicHappens`: 70 | 71 | ```html 72 | 73 | 77 | ``` 78 | 79 | You can get a hoodie for your app with only a few clicks over on [hood.ie](http://hood.ie). 80 | 81 | If you like to host the magic yourselves, [there you go](https://github.com/hoodiehq/hoodie-app). 82 | 83 | 84 | Dependencies 85 | ------------ 86 | 87 | hoodie borrows some functionality from [jQuery](http://jquery.com), but we plan to remove this dependency soon. 88 | 89 | 90 | Feedback 91 | -------- 92 | 93 | If you have any kind of feedback, especially regarding hoodie's API, please [let us know](https://github.com/hoodiehq/hoodie.js/issues). You can also find us on Twitter: [@hoodiehq](https://twitter.com/hoodiehq) 94 | 95 | 96 | Contribute 97 | ---------- 98 | 99 | Want to join the revolution? Here's a [quickstart for developers](https://github.com/hoodiehq/hoodie.js/blob/master/quickstart_for_developers.md) 100 | 101 | 102 | License & Copyright 103 | ------------------- 104 | 105 | © 2012 Gregor Martynus 106 | Licensed under the Apache License 2.0. 107 | -------------------------------------------------------------------------------- /Cakefile: -------------------------------------------------------------------------------- 1 | fs = require 'fs' 2 | {print} = require 'util' 3 | {spawn, exec} = require 'child_process' 4 | 5 | timeout = null 6 | compile = (callback, watch = false) -> 7 | if watch 8 | coffee = spawn 'coffee', ['-c', '-b', '-o', 'compiled', '-w', '.'] 9 | else 10 | coffee = spawn 'coffee', ['-c', '-b', '-o', 'compiled', '.'] 11 | 12 | coffee.stderr.on 'data', (data) -> 13 | process.stderr.write data.toString() 14 | coffee.stdout.on 'data', (data) -> 15 | clear() 16 | print data.toString() 17 | 18 | if callback and watch 19 | clearTimeout timeout 20 | timeout = setTimeout callback, 100 21 | 22 | coffee.on 'exit', (code) -> 23 | callback?() if code is 0 24 | 25 | clear = -> 26 | process.stdout.write '\u001B[2J\u001B[0;0f' 27 | 28 | test = -> 29 | phantom = spawn 'phantomjs', ['test/lib/phantomjs_test_runner.coffee', 'test/index.html'] 30 | 31 | phantom.stderr.on 'data', (data) -> 32 | process.stderr.write data.toString() 33 | phantom.stdout.on 'data', (data) -> 34 | print data.toString() 35 | 36 | build = (doMinify = false) -> 37 | # the files need to be in a specific order, 38 | # as some modules depend on others (e.g. 39 | # AccountRemote > Remote) 40 | files = """ 41 | src/events.coffee 42 | src/hoodie.coffee 43 | src/core/account.coffee 44 | src/core/config.coffee 45 | src/core/email.coffee 46 | src/core/errors.coffee 47 | src/core/store.coffee 48 | src/core/remote.coffee 49 | src/core/account_remote.coffee 50 | src/core/local_store.coffee 51 | src/extensions/share.coffee 52 | src/extensions/user.coffee 53 | src/extensions/global.coffee 54 | src/extensions/share_instance.coffee 55 | """.split("\n") 56 | 57 | console.log "concatinating files ..." 58 | coffee = spawn 'coffee', ['-j', 'hoodie.js', '-c', '-b'].concat(files) 59 | coffee.on 'exit', (code) -> 60 | return unless doMinify 61 | console.log "minifying ..." 62 | spawn 'uglifyjs', ['-o', 'hoodie.min.js', 'hoodie.js'] 63 | 64 | task 'compile', 'Build lib/', -> 65 | compile() 66 | 67 | task 'watch', 'Build lib/ and watch for changes', -> 68 | compile(null, true) 69 | 70 | task 'test', 'Run all test', -> 71 | compile test 72 | 73 | task 'autotest', 'Run all tests & rerun on file changes', -> 74 | clearAndTest = -> 75 | clear() 76 | test() 77 | 78 | compile clearAndTest, true 79 | 80 | task 'console', 'run a browser console, from command line, hell yeah', -> 81 | spawn process.env["EDITOR"], ['/tmp/phantom_command.coffee'] 82 | 83 | spawn 'touch', ['/tmp/phantom_command.coffee'] 84 | spawn 'coffee', ['-b', '-c', '-w', '/tmp/phantom_command.coffee'] 85 | 86 | phantom = spawn 'phantomjs', ['test/lib/phantomjs_console.coffee', 'index.html'] 87 | phantom.stderr.on 'data', (data) -> 88 | process.stderr.write data.toString() 89 | phantom.stdout.on 'data', (data) -> 90 | print data.toString() 91 | 92 | 93 | task 'build', 'build hoodie.min.js', -> 94 | build true # true = minify 95 | 96 | task 'autobuild', 'build hoodie.min.js', -> 97 | 98 | compile build, true 99 | 100 | 101 | task 'docs', 'create docs from code', -> 102 | console.log "" 103 | console.log "doesn't work correctly (ignores -t parameter). Please run manually:" 104 | console.log 'groc -t src/ "src/**/*.coffee"' 105 | console.log "" 106 | return 107 | groc = spawn 'groc', ['-t "src/"', 'src/**/*.coffee'] 108 | groc.stdout.on 'data', (data) -> print data.toString() 109 | groc.on 'exit', (status) -> callback?() if status is 0 110 | 111 | task 'wishlist', 'create docs from dream code', -> 112 | groc = spawn 'groc', ['-t wishlist/', '-o whishlist/doc', 'wishlist/**/*.js'] 113 | groc.stdout.on 'data', (data) -> print data.toString() 114 | groc.on 'exit', (status) -> callback?() if status is 0 115 | 116 | task 'all', 'one cake to rule them all', -> 117 | exec 'cake compile && cake build && cake docs', (err) -> 118 | throw err if err -------------------------------------------------------------------------------- /test/lib/phantomjs_test_runner.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Wait until the test condition is true or a timeout occurs. Useful for waiting 3 | # on a server response or for a ui change (fadeIn, etc.) to occur. 4 | # 5 | # @param testFx javascript condition that evaluates to a boolean, 6 | # it can be passed in as a string (e.g.: "1 == 1" or "$('#bar').is(':visible')" or 7 | # as a callback function. 8 | # @param onReady what to do when testFx condition is fulfilled, 9 | # it can be passed in as a string (e.g.: "1 == 1" or "$('#bar').is(':visible')" or 10 | # as a callback function. 11 | # @param timeOutMillis the max amount of time to wait. If not specified, 3 sec is used. 12 | # 13 | waitFor = (testFx, onReady, timeOutMillis=3000) -> 14 | start = new Date().getTime() 15 | condition = false 16 | f = -> 17 | if (new Date().getTime() - start < timeOutMillis) and not condition 18 | # If not time-out yet and condition not yet fulfilled 19 | condition = (if typeof testFx is 'string' then eval testFx else testFx()) #< defensive code 20 | else 21 | if not condition 22 | # If condition still not fulfilled (timeout but condition is 'false') 23 | console.log "'waitFor()' timeout" 24 | phantom.exit(1) 25 | else 26 | # Condition fulfilled (timeout and/or condition is 'true') 27 | # console.log "'waitFor()' finished in #{new Date().getTime() - start}ms." 28 | if typeof onReady is 'string' then eval onReady else onReady() #< Do what it's supposed to do once the condition is fulfilled 29 | clearInterval interval #< Stop this interval 30 | interval = setInterval f, 100 #< repeat check every 100ms 31 | 32 | # if phantom.args.length isnt 1 33 | # console.log 'Usage: run-jasmine.coffee URL' 34 | # phantom.exit() 35 | 36 | page = new WebPage() 37 | 38 | # Route "console.log()" calls from within the Page context to the main Phantom context (i.e. current "this") 39 | page.onConsoleMessage = (msg, line, file)-> 40 | console.log msg 41 | 42 | page.onError = (msg, trace) -> 43 | try 44 | 45 | trace_parts = for item in trace 46 | "#{item.file}:#{item.line}" 47 | 48 | code = """ 49 | getScriptPosition = function() { 50 | var line = #{trace[0].line}, 51 | before = 10, 52 | after = 10; 53 | $.get('#{trace[0].file}', function(data) { 54 | lines = data.split(/\\n/) 55 | 56 | console.log("") 57 | console.log("ERROR") 58 | console.log("#{msg.replace(/"/g, "\\\"")}") 59 | console.log("") 60 | console.log("#{trace_parts.shift()}") 61 | for(var i = line - before; i <= line + after; i++) { 62 | console.log(i, (i == line) ? ': => ' : ': ', lines[i - 1]) 63 | } 64 | console.log("") 65 | console.log("TRACE") 66 | console.log("#{trace_parts.join "\\n"}") 67 | }) 68 | } 69 | """ 70 | eval code 71 | 72 | page.evaluate getScriptPosition 73 | 74 | catch e 75 | console.log "" 76 | console.log "!!! phantomJS error !!!" 77 | console.log e 78 | console.log code 79 | console.log "" 80 | 81 | 82 | page.open phantom.args[0], (status) -> 83 | if status isnt 'success' 84 | console.log status + '! Unable to access ' + phantom.args[0] 85 | phantom.exit() 86 | else 87 | 88 | waitFor -> 89 | page.evaluate -> 90 | return not window.jas.currentRunner_.queue.running 91 | , -> 92 | hasErrors = page.evaluate -> 93 | 94 | 95 | 96 | console.log '' 97 | console.log "================================================================================" 98 | console.log document.body.querySelector('.runner .description').innerText 99 | console.log "================================================================================" 100 | console.log '' 101 | 102 | list = document.body.querySelectorAll('.failed > .description, .failed > .messages > .resultMessage') 103 | 104 | hasErrors = list.length 105 | for el in list 106 | i = '' 107 | e = el 108 | until e.className == 'jasmine_reporter' 109 | e = e.parentNode 110 | i += ' ' if /failed/.test(e.className) 111 | 112 | i += '=> ' if el.className == 'resultMessage fail' 113 | console.log i + el.innerText 114 | console.log '' if el.className == 'resultMessage fail' 115 | 116 | return hasErrors 117 | 118 | if hasErrors 119 | phantom.exit(1) 120 | else 121 | phantom.exit() 122 | -------------------------------------------------------------------------------- /test/specs/extensions/user.spec.coffee: -------------------------------------------------------------------------------- 1 | # describe "Hoodie.User", -> 2 | # beforeEach -> 3 | # @hoodie = new Mocks.Hoodie 4 | 5 | # describe "constructor", -> 6 | # beforeEach -> 7 | # spyOn(@hoodie, "open").andReturn 'storeApi' 8 | 9 | # it "should return a shortcut for hoodie.open", -> 10 | # user = new Hoodie.User @hoodie 11 | # expect(user('uuid123')).toBe 'storeApi' 12 | # expect(@hoodie.open).wasCalledWith 'user/uuid123/public', prefix: '$public' 13 | 14 | # it "should pass options", -> 15 | # user = new Hoodie.User @hoodie 16 | # user 'uuid123', sync: true 17 | # expect(@hoodie.open).wasCalledWith 'user/uuid123/public', prefix: '$public', sync: true 18 | 19 | # it "should extend hoodie.store API with publish / unpublish methods", -> 20 | # spyOn(@hoodie.store, "decoratePromises") 21 | # new Hoodie.User @hoodie 22 | # expect(@hoodie.store.decoratePromises).wasCalled() 23 | # {publish, unpublish} = @hoodie.store.decoratePromises.mostRecentCall.args[0] 24 | # expect(typeof publish).toBe 'function' 25 | # expect(typeof unpublish).toBe 'function' 26 | # # /constructor 27 | 28 | # describe "hoodie.store promise decorations", -> 29 | # beforeEach -> 30 | # @storeDefer = @hoodie.defer() 31 | # spyOn(@hoodie.store, "update") 32 | 33 | # describe "#publish(properties)", -> 34 | # _when "promise returns one object", -> 35 | # beforeEach -> 36 | # @promise = @storeDefer.resolve({type: 'task', id: '123', title: 'milk'}) 37 | # @promise.hoodie = @hoodie 38 | 39 | # _and "no properties passed", -> 40 | # it "should update object returned by promise with $public: true", -> 41 | # Hoodie.User::_storePublish.apply(@promise, []) 42 | # expect(@hoodie.store.update).wasCalledWith 'task', '123', {$public: true} 43 | 44 | # _and "properties passed as array", -> 45 | # it "should update object returned by promise with $public: ['title', 'owner']", -> 46 | # properties = ['title', 'owner'] 47 | # Hoodie.User::_storePublish.apply(@promise, [properties]) 48 | # expect(@hoodie.store.update).wasCalledWith 'task', '123', {$public: ['title', 'owner']} 49 | 50 | # _when "promise returns multiple objects", -> 51 | # beforeEach -> 52 | # @promise = @storeDefer.resolve [ 53 | # {type: 'task', id: '123', title: 'milk'} 54 | # {type: 'task', id: '456', title: 'milk'} 55 | # ] 56 | # @promise.hoodie = @hoodie 57 | 58 | # _and "no properties passed", -> 59 | # it "should update object returned by promise with $public: true", -> 60 | # Hoodie.User::_storePublish.apply(@promise, []) 61 | # expect(@hoodie.store.update).wasCalledWith 'task', '123', {$public: true} 62 | # expect(@hoodie.store.update).wasCalledWith 'task', '456', {$public: true} 63 | 64 | # _and "properties passed as array", -> 65 | # it "should update object returned by promise with $public: ['title', 'owner']", -> 66 | # properties = ['title', 'owner'] 67 | # Hoodie.User::_storePublish.apply(@promise, [properties]) 68 | # expect(@hoodie.store.update).wasCalledWith 'task', '123', {$public: ['title', 'owner']} 69 | # expect(@hoodie.store.update).wasCalledWith 'task', '456', {$public: ['title', 'owner']} 70 | # # /publish() 71 | 72 | # describe "#unpublish()", -> 73 | # _when "promise returns one object that is public", -> 74 | # beforeEach -> 75 | # @promise = @storeDefer.resolve 76 | # type: 'task' 77 | # id: '123' 78 | # title: 'milk' 79 | # $public: true 80 | # @promise.hoodie = @hoodie 81 | 82 | # it "should update object returned by promise with $public: false", -> 83 | # Hoodie.User::_storeUnpublish.apply(@promise, []) 84 | # expect(@hoodie.store.update).wasCalledWith 'task', '123', {$public: false} 85 | 86 | # _when "promise returns one object that is not public", -> 87 | # beforeEach -> 88 | # @promise = @storeDefer.resolve 89 | # type: 'task' 90 | # id: '123' 91 | # title: 'milk' 92 | # @promise.hoodie = @hoodie 93 | 94 | # it "should not update object returned by promise", -> 95 | # Hoodie.User::_storeUnpublish.apply(@promise, []) 96 | # expect(@hoodie.store.update).wasNotCalled() 97 | 98 | # _when "promise returns multiple objects, of which some are public", -> 99 | # beforeEach -> 100 | # @promise = @storeDefer.resolve [ 101 | # {type: 'task', id: '123', title: 'milk'} 102 | # {type: 'task', id: '456', title: 'milk', $public: true} 103 | # {type: 'task', id: '789', title: 'milk', $public: ['title', 'owner']} 104 | # ] 105 | # @promise.hoodie = @hoodie 106 | 107 | # it "should update object returned by promise with $public: true", -> 108 | # Hoodie.User::_storeUnpublish.apply(@promise, []) 109 | # expect(@hoodie.store.update).wasNotCalledWith 'task', '123', {$public: false} 110 | # expect(@hoodie.store.update).wasCalledWith 'task', '456', {$public: false} 111 | # expect(@hoodie.store.update).wasCalledWith 'task', '789', {$public: false} 112 | # # # /#unpublish() 113 | # # /hoodie.store promise decorations -------------------------------------------------------------------------------- /wishlist/public_user_stores.js: -------------------------------------------------------------------------------- 1 | // Public User Stores 2 | // ==================== 3 | 4 | // Every user has a store which is private by default. Nobody but the user 5 | // himself is able to access this data, authenticated by a username and a 6 | // password. 7 | // 8 | // Beyond that, users can make specific objects available to other users, 9 | // read only. This document describes how that works. 10 | 11 | 12 | // ## Make objects public 13 | // 14 | // Objects can either be made public entirely, or selected attributes of 15 | // the an object. To make an object public, pass the `public: true` option 16 | // as demonstrated in the code examples: 17 | 18 | // make object entirely public 19 | hoodie.store.find('task', '123').publish() 20 | 21 | // or: make seleceted attributes of objects public 22 | hoodie.store.find('task', '123').publish(['title', 'description']) 23 | 24 | // make a public object private again 25 | hoodie.store.find('task', '123').unpublish() 26 | 27 | // or: make certain attributes of a published object private again 28 | hoodie.store.find('task', '123').unpublish(['description']) 29 | 30 | // add a new object and make it public 31 | hoodie.store.add('task', object).publish() 32 | 33 | 34 | // ## Open public objects 35 | 36 | // I can acces public objects from other users. 37 | hoodie.user("joey").store.findAll( function(publicObjects){ 38 | /* do something with Joey's public objects */ 39 | }) 40 | 41 | // I can also pull all objects from Joey's store and save them 42 | // into my own store 43 | hoodie.user("joey").pull() 44 | 45 | 46 | // ## use case1: private instagram-ish app 47 | // 48 | // Our photo share exmaple app to showcase hoodie: 49 | // hoodiehq.github.com/app-photo 50 | // 51 | // There is only one Model: Photo. The challange is that a 52 | // picture can be public, so everyone knowing my username 53 | // can see it in my public photo stream. 54 | 55 | 56 | // ### Scenario 1 57 | 58 | // I want to make a photo public 59 | // 60 | hoodie.store.find("photo", "abc4567").publish() 61 | 62 | 63 | // ### Scenario 2 64 | 65 | // I want to make a public photo private again 66 | // 67 | hoodie.store.find("photo", "abc4567").unpublish() 68 | 69 | 70 | // ### Scenario 3 71 | 72 | // I want to see my friends photos 73 | // 74 | hoodie.user("friendname").store.findAll( showPhotos ) 75 | 76 | 77 | // ### Scenario 4 78 | 79 | // show most recently uploaded public photos 80 | // 81 | hoodie.global.get("most_recent_photos", {page: 2}) 82 | .done( function(photos) { 83 | renderPhotos(photos) 84 | }) 85 | 86 | 87 | // ## usecase 2: whiskie.net 88 | // 89 | // whiskie is a music player frontend for tumblr.com. It allows you 90 | // to make a favorites playlist that is publically visible. It also 91 | // counts track plays to meassure its popularity 92 | // 93 | // 94 | // **Models** 95 | // 96 | // * Profiles 97 | // * Tracks (have global play count) 98 | // * Favorites (belong to track and profile) 99 | // 100 | // 101 | // **Hoodie Challanges** 102 | // 103 | // 1. public profiles of users with their favorites 104 | // 2. global play counts of tracks 105 | // 106 | 107 | 108 | // ### Scenario 1 109 | 110 | // A user plays a track. We need to make sure that the track object exists 111 | // and then we want to increase its play count by creating a related 112 | // play object. 113 | // 114 | function playTrack( track ) { 115 | 116 | hoodie.store.findOrAdd( "track", track.id, track).publish() 117 | hoodie.store.add("play", {trackId: track.id}).publish() 118 | } 119 | 120 | tumblrTrack = { 121 | "id" : "track123id", 122 | "artist" : "Queen", 123 | "name" : "Champion", 124 | "url" : "http://tumblr.com/awoft32p", 125 | "coverImgUrl" : "http://images.tumblr.com/ytaw3t.png" 126 | } 127 | playTrack( tumblrTrack ) 128 | 129 | 130 | // ### Scenario 2 131 | 132 | // I want to favorite or unfavorite a track 133 | // 134 | function favoriteTrack( track ) { 135 | hoodie.store.add( "favorite", track.id) 136 | } 137 | 138 | function unfavoriteTrack( track ) { 139 | hoodie.store.remove( "favorite", track.id) 140 | } 141 | 142 | // ### Scenario 3 143 | 144 | // Show favorites from a user http://whiskie.net/user/espy 145 | // 146 | hoodie.user('espy').store.findAll("favorite") 147 | .done( renderFavorites ) 148 | 149 | function renderFavorites (favorites) { 150 | /* get global playcounts */ 151 | var favoritesIds = favorites.map( function(fav) { return fav.id }) 152 | 153 | hoodie.global.get("tracks_with_play_counts", { 154 | ids: favoritesIds 155 | }).done( function(tracks) { 156 | 157 | for (var i = 0; i < tracks.length; i++) { 158 | var track = tracks[i] 159 | $("
  • "+track.name+" ("+track.playCount+")
  • ").appendTo("#tracks") 160 | } 161 | }) 162 | } 163 | 164 | // ### Scenario 4 165 | 166 | // Currently trendings tracks 167 | // 168 | hoodie.global.get("trending_tracks") 169 | .done( renderTrendingTracks ) 170 | 171 | 172 | // 173 | // ### random thoughts 174 | // 175 | // dunno if it makes any sense yet 176 | // 177 | 178 | // pull all objects of type 'favorite' from epsy's public store 179 | hoodie.user('espy').pull("favorite") 180 | 181 | // a pull from a remote store (public user store / a share) 182 | // adds a special attribute `$store` to the objects, so that 183 | // they can be distinced from my own objects: 184 | favoriteObject = { 185 | "$type" : 'favorite', 186 | id : "trackXYZ", 187 | "$store" : 'user/espy/public' 188 | } 189 | 190 | // hmm ... or maybe with a hash map? 191 | favoriteObject = { 192 | "$type" : 'favorite', 193 | id : "trackXYZ", 194 | "$stores" : { 195 | 'user/espy/public': 1 196 | } 197 | } -------------------------------------------------------------------------------- /doc/core/email.html: -------------------------------------------------------------------------------- 1 | core/email
    src/core/email.coffee
    #

    Sending emails. Not unicorns

    # 2 | 3 | class Hoodie.Email

    Constructor

    # 4 | constructor : (@hoodie) -> 5 |

    TODO 6 | let's subscribe to general _email changes and provide 7 | an on interface, so devs can listen to events like:

    8 | 9 |
      10 |
    • hoodie.email.on 'sent', -> ...
    • 11 |
    • hoodie.email.on 'error', -> ...
    • 12 |

    send

    #

    sends an email and returns a promise

    send : (emailAttributes = {}) -> 13 | defer = @hoodie.defer() 14 | attributes = $.extend {}, emailAttributes 15 | 16 | unless @_isValidEmail emailAttributes.to 17 | attributes.error = "Invalid email address (#{attributes.to or 'empty'})" 18 | return defer.reject(attributes).promise() 19 | 20 | @hoodie.store.add('$email', attributes).then (obj) => 21 | @_handleEmailUpdate(defer, obj) 22 | 23 | defer.promise() 24 |

    PRIVATE

    # 25 | _isValidEmail : (email = '') -> 26 | /@/.test email 27 | 28 | _handleEmailUpdate : (defer, attributes = {}) => 29 | if attributes.error 30 | defer.reject attributes 31 | else if attributes.deliveredAt 32 | defer.resolve attributes 33 | else 34 | @hoodie.remote.one "updated:$email:#{attributes.id}", (attributes) => @_handleEmailUpdate(defer, attributes)

    extend Hoodie

    Hoodie.extend 'email', Hoodie.Email
    -------------------------------------------------------------------------------- /vendor/prism/prism.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prism: Lightweight, robust, elegant syntax highlighting 3 | * MIT license http://www.opensource.org/licenses/mit-license.php/ 4 | * @author Lea Verou http://lea.verou.me 5 | */(function(){var e=/\blang(?:uage)?-(?!\*)(\w+)\b/i,t=self.Prism={util:{type:function(e){return Object.prototype.toString.call(e).match(/\[object (\w+)\]/)[1]},clone:function(e){var n=t.util.type(e);switch(n){case"Object":var r={};for(var i in e)e.hasOwnProperty(i)&&(r[i]=t.util.clone(e[i]));return r;case"Array":return e.slice()}return e}},languages:{extend:function(e,n){var r=t.util.clone(t.languages[e]);for(var i in n)r[i]=n[i];return r},insertBefore:function(e,n,r,i){i=i||t.languages;var s=i[e],o={};for(var u in s)if(s.hasOwnProperty(u)){if(u==n)for(var a in r)r.hasOwnProperty(a)&&(o[a]=r[a]);o[u]=s[u]}return i[e]=o},DFS:function(e,n){for(var r in e){n.call(e,r,e[r]);t.util.type(e)==="Object"&&t.languages.DFS(e[r],n)}}},highlightAll:function(e,n){var r=document.querySelectorAll('code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code');for(var i=0,s;s=r[i++];)t.highlightElement(s,e===!0,n)},highlightElement:function(r,i,s){var o,u,a=r;while(a&&!e.test(a.className))a=a.parentNode;if(a){o=(a.className.match(e)||[,""])[1];u=t.languages[o]}if(!u)return;r.className=r.className.replace(e,"").replace(/\s+/g," ")+" language-"+o;a=r.parentNode;/pre/i.test(a.nodeName)&&(a.className=a.className.replace(e,"").replace(/\s+/g," ")+" language-"+o);var f=r.textContent;if(!f)return;f=f.replace(/&/g,"&").replace(//g,">").replace(/\u00a0/g," ");var l={element:r,language:o,grammar:u,code:f};t.hooks.run("before-highlight",l);if(i&&self.Worker){var c=new Worker(t.filename);c.onmessage=function(e){l.highlightedCode=n.stringify(JSON.parse(e.data));l.element.innerHTML=l.highlightedCode;s&&s.call(l.element);t.hooks.run("after-highlight",l)};c.postMessage(JSON.stringify({language:l.language,code:l.code}))}else{l.highlightedCode=t.highlight(l.code,l.grammar);l.element.innerHTML=l.highlightedCode;s&&s.call(r);t.hooks.run("after-highlight",l)}},highlight:function(e,r){return n.stringify(t.tokenize(e,r))},tokenize:function(e,n){var r=t.Token,i=[e],s=n.rest;if(s){for(var o in s)n[o]=s[o];delete n.rest}e:for(var o in n){if(!n.hasOwnProperty(o)||!n[o])continue;var u=n[o],a=u.inside,f=!!u.lookbehind||0;u=u.pattern||u;for(var l=0;le.length)break e;if(c instanceof r)continue;u.lastIndex=0;var h=u.exec(c);if(h){f&&(f=h[1].length);var p=h.index-1+f,h=h[0].slice(f),d=h.length,v=p+d,m=c.slice(0,p+1),g=c.slice(v+1),y=[l,1];m&&y.push(m);var b=new r(o,a?t.tokenize(h,a):h);y.push(b);g&&y.push(g);Array.prototype.splice.apply(i,y)}}}return i},hooks:{all:{},add:function(e,n){var r=t.hooks.all;r[e]=r[e]||[];r[e].push(n)},run:function(e,n){var r=t.hooks.all[e];if(!r||!r.length)return;for(var i=0,s;s=r[i++];)s(n)}}},n=t.Token=function(e,t){this.type=e;this.content=t};n.stringify=function(e){if(typeof e=="string")return e;if(Object.prototype.toString.call(e)=="[object Array]")return e.map(n.stringify).join("");var r={type:e.type,content:n.stringify(e.content),tag:"span",classes:["token",e.type],attributes:{}};r.type=="comment"&&(r.attributes.spellcheck="true");t.hooks.run("wrap",r);var i="";for(var s in r.attributes)i+=s+'="'+(r.attributes[s]||"")+'"';return"<"+r.tag+' class="'+r.classes.join(" ")+'" '+i+">"+r.content+""};if(!self.document){self.addEventListener("message",function(e){var n=JSON.parse(e.data),r=n.language,i=n.code;self.postMessage(JSON.stringify(t.tokenize(i,t.languages[r])));self.close()},!1);return}var r=document.getElementsByTagName("script");r=r[r.length-1];if(r){t.filename=r.src;document.addEventListener&&!r.hasAttribute("data-manual")&&document.addEventListener("DOMContentLoaded",t.highlightAll)}})();; 6 | Prism.languages.markup={comment:/<!--[\w\W]*?--(>|>)/g,prolog:/<\?.+?\?>/,doctype:/<!DOCTYPE.+?>/,cdata:/<!\[CDATA\[[\w\W]+?]]>/i,tag:{pattern:/<\/?[\w:-]+\s*(?:\s+[\w:-]+(?:=(?:("|')(\\?[\w\W])*?\1|\w+))?\s*)*\/?>/gi,inside:{tag:{pattern:/^<\/?[\w:-]+/i,inside:{punctuation:/^<\/?/,namespace:/^[\w-]+?:/}},"attr-value":{pattern:/=(?:('|")[\w\W]*?(\1)|[^\s>]+)/gi,inside:{punctuation:/=|>|"/g}},punctuation:/\/?>/g,"attr-name":{pattern:/[\w:-]+/g,inside:{namespace:/^[\w-]+?:/}}}},entity:/&#?[\da-z]{1,8};/gi};Prism.hooks.add("wrap",function(e){e.type==="entity"&&(e.attributes.title=e.content.replace(/&/,"&"))});; 7 | Prism.languages.css={comment:/\/\*[\w\W]*?\*\//g,atrule:/@[\w-]+?(\s+[^;{]+)?(?=\s*{|\s*;)/gi,url:/url\((["']?).*?\1\)/gi,selector:/[^\{\}\s][^\{\}]*(?=\s*\{)/g,property:/(\b|\B)[a-z-]+(?=\s*:)/ig,string:/("|')(\\?.)*?\1/g,important:/\B!important\b/gi,ignore:/&(lt|gt|amp);/gi,punctuation:/[\{\};:]/g};Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{style:{pattern:/(<|<)style[\w\W]*?(>|>)[\w\W]*?(<|<)\/style(>|>)/ig,inside:{tag:{pattern:/(<|<)style[\w\W]*?(>|>)|(<|<)\/style(>|>)/ig,inside:Prism.languages.markup.tag.inside},rest:Prism.languages.css}}});; 8 | Prism.languages.clike={comment:{pattern:/(^|[^\\])(\/\*[\w\W]*?\*\/|\/\/.*?(\r?\n|$))/g,lookbehind:!0},string:/("|')(\\?.)*?\1/g,keyword:/\b(if|else|while|do|for|return|in|instanceof|function|new|try|catch|finally|null|break|continue)\b/g,"boolean":/\b(true|false)\b/g,number:/\b-?(0x)?\d*\.?[\da-f]+\b/g,operator:/[-+]{1,2}|!|=?<|=?>|={1,2}|(&){1,2}|\|?\||\?|\*|\//g,ignore:/&(lt|gt|amp);/gi,punctuation:/[{}[\];(),.:]/g};; 9 | Prism.languages.javascript=Prism.languages.extend("clike",{keyword:/\b(var|let|if|else|while|do|for|return|in|instanceof|function|new|with|typeof|try|catch|finally|null|break|continue)\b/g,number:/\b(-?(0x)?\d*\.?[\da-f]+|NaN|-?Infinity)\b/g});Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g,lookbehind:!0}});Prism.languages.markup&&Prism.languages.insertBefore("markup","tag",{script:{pattern:/(<|<)script[\w\W]*?(>|>)[\w\W]*?(<|<)\/script(>|>)/ig,inside:{tag:{pattern:/(<|<)script[\w\W]*?(>|>)|(<|<)\/script(>|>)/ig,inside:Prism.languages.markup.tag.inside},rest:Prism.languages.javascript}}});; 10 | -------------------------------------------------------------------------------- /src/hoodie.coffee: -------------------------------------------------------------------------------- 1 | # Hoodie 2 | # -------- 3 | # 4 | # the door to world domination (apps) 5 | # 6 | 7 | class Hoodie extends Events 8 | 9 | # ## Settings 10 | 11 | # `online` (read-only) 12 | online : true 13 | 14 | # `checkConnectionInterval` (read-only) 15 | checkConnectionInterval : 30000 # 30 seconds 16 | 17 | # ## Constructor 18 | 19 | # When initializing a hoodie instance, an optional URL 20 | # can be passed. That's the URL of a hoodie backend. 21 | # If no URL passed it defaults to the current domain 22 | # with an `api` subdomain. 23 | # 24 | # // init a new hoodie instance 25 | # hoodie = new Hoodie 26 | # 27 | constructor : (@baseUrl) -> 28 | 29 | if @baseUrl 30 | # remove trailing slash(es) 31 | @baseUrl = @baseUrl.replace /\/+$/, '' 32 | 33 | else 34 | @baseUrl = location.protocol + "//api." + location.hostname.replace(/^www\./, '') 35 | 36 | # init core modules 37 | @store = new @constructor.LocalStore this 38 | @config = new @constructor.Config this 39 | @account = new @constructor.Account this 40 | @remote = new @constructor.AccountRemote this 41 | 42 | # init extensions 43 | @_loadExtensions() 44 | 45 | # check connection 46 | @checkConnection() 47 | 48 | 49 | # ## Requests 50 | 51 | # use this method to send requests to the hoodie backend. 52 | # 53 | # promise = hoodie.request('GET', '/user_database/doc_id') 54 | # 55 | request : (type, url, options = {}) -> 56 | 57 | # if a relative path passed, prefix with @baseUrl 58 | url = "#{@baseUrl}#{url}" unless /^http/.test url 59 | 60 | defaults = 61 | type : type 62 | url : url 63 | xhrFields : withCredentials: true 64 | crossDomain : true 65 | dataType : 'json' 66 | 67 | $.ajax $.extend defaults, options 68 | 69 | 70 | # ## Check Connection 71 | 72 | # the `checkConnection` method is used, well, to check if 73 | # the hoodie backend is reachable at `baseUrl` or not. 74 | # Check Connection is automatically called on startup 75 | # and then each 30 seconds. If it fails, it 76 | # 77 | # - sets `hoodie.online = false` 78 | # - triggers `offline` event 79 | # - sets `checkConnectionInterval = 3000` 80 | # 81 | # when connection can be reestablished, it 82 | # 83 | # - sets `hoodie.online = true` 84 | # - triggers `online` event 85 | # - sets `checkConnectionInterval = 30000` 86 | _checkConnectionRequest : null 87 | checkConnection : => 88 | return @_checkConnectionRequest if @_checkConnectionRequest?.state?() is 'pending' 89 | 90 | @_checkConnectionRequest = @request('GET', '/') 91 | .pipe( @_handleCheckConnectionSuccess, @_handleCheckConnectionError ) 92 | 93 | 94 | # ## Open stores 95 | 96 | # generic method to open a store. Used by 97 | # 98 | # * hoodie.remote 99 | # * hoodie.user("joe") 100 | # * hoodie.global 101 | # * ... and more 102 | # 103 | # hoodie.open("some_store_name").findAll() 104 | # 105 | open : (storeName, options = {}) -> 106 | $.extend options, name: storeName 107 | new Hoodie.Remote this, options 108 | 109 | 110 | # ## uuid 111 | 112 | # helper to generate unique ids. 113 | uuid : (len = 7) -> 114 | chars = '0123456789abcdefghijklmnopqrstuvwxyz'.split('') 115 | radix = chars.length 116 | ( 117 | chars[ 0 | Math.random()*radix ] for i in [0...len] 118 | ).join('') 119 | 120 | 121 | # ## Defers / Promises 122 | 123 | # returns a defer object for custom promise handlings. 124 | # Promises are heavely used throughout the code of hoodie. 125 | # We currently borrow jQuery's implementation: 126 | # http://api.jquery.com/category/deferred-object/ 127 | # 128 | # defer = hoodie.defer() 129 | # if (good) { 130 | # defer.resolve('good.') 131 | # } else { 132 | # defer.reject('not good.') 133 | # } 134 | # return defer.promise() 135 | # 136 | defer: $.Deferred 137 | 138 | # 139 | isPromise : (obj) -> 140 | typeof obj?.done is 'function' and typeof obj.resolve is 'undefined' 141 | 142 | # 143 | resolve : => 144 | @defer().resolve().promise() 145 | 146 | # 147 | reject : => 148 | @defer().reject().promise() 149 | 150 | # 151 | resolveWith : => 152 | @defer().resolve( arguments... ).promise() 153 | 154 | # 155 | rejectWith : => 156 | @defer().reject( arguments... ).promise() 157 | 158 | 159 | # dispose 160 | # --------- 161 | 162 | # if a hoodie instance is not needed anymore, it can 163 | # be disposed using this method. A `dispose` event 164 | # gets triggered that the modules react on. 165 | dispose : -> 166 | @trigger 'dispose' 167 | 168 | 169 | # ## Extending hoodie 170 | 171 | # You can either extend the Hoodie class, or a hoodie 172 | # instance dooring runtime 173 | # 174 | # Hoodie.extend('magic1', funcion(hoodie) { /* ... */ }) 175 | # hoodie = new Hoodie 176 | # hoodie.extend('magic2', function(hoodie) { /* ... */ }) 177 | # hoodie.magic1.doSomething() 178 | # hoodie.magic2.doSomethingElse() 179 | @extend : (name, Module) -> 180 | @_extensions ||= {} 181 | @_extensions[name] = Module 182 | extend : (name, Module) -> 183 | @[name] = new Module this 184 | 185 | # ## Private 186 | 187 | # 188 | _loadExtensions: -> 189 | for instanceName, Module of @constructor._extensions 190 | @[instanceName] = new Module this 191 | 192 | # 193 | _handleCheckConnectionSuccess : (response) => 194 | @checkConnectionInterval = 30000 195 | window.setTimeout @checkConnection, @checkConnectionInterval 196 | 197 | unless @online 198 | @trigger 'reconnected' 199 | @online = true 200 | return @defer().resolve() 201 | 202 | # 203 | _handleCheckConnectionError : (response) => 204 | @checkConnectionInterval = 3000 205 | window.setTimeout @checkConnection, @checkConnectionInterval 206 | if @online 207 | @trigger 'disconnected' 208 | @online = false 209 | return @defer().reject() -------------------------------------------------------------------------------- /doc/extensions/user.html: -------------------------------------------------------------------------------- 1 | extensions/user
    src/extensions/user.coffee

    User

    the User Module provides a simple API to find objects from other users public 2 | stores

    #

    For example, the syntax to find all objects from user "Joe" looks like this:

    #
    hoodie.user("Joe").findAll().done( handleObjects )
     3 | 
    # 4 | class Hoodie.User 5 | 6 | constructor: (@hoodie) ->

    extend hodie.store promise API

    @hoodie.store.decoratePromises 7 | publish : @_storePublish 8 | unpublish : @_storeUnpublish

    vanilla API syntax: 9 | hoodie.user('uuid1234').findAll()

    `return this.api` 10 | 11 | # 12 | api : (userHash, options = {}) => 13 | $.extend options, prefix: '$public' 14 | @hoodie.open "user/#{userHash}/public", options

    hoodie.store decorations

    15 | 16 |

    hoodie.store decorations add custom methods to promises returned 17 | by hoodie.store methods like find, add or update. All methods return 18 | methods again that will be executed in the scope of the promise, but 19 | with access to the current hoodie instance

    publish

    20 | 21 |

    publish an object. If an array of properties passed, publish only these 22 | attributes and hide the remaining ones. If no properties passed, publish 23 | the entire object.

    _storePublish : (properties) -> 24 | @pipe (objects) => 25 | objects = [objects] unless $.isArray objects 26 | for object in objects 27 | @hoodie.store.update object.type, object.id, $public: properties or true 28 |

    unpublish

    29 | 30 |

    unpublish

    _storeUnpublish : -> 31 | @pipe (objects) => 32 | objects = [objects] unless $.isArray objects 33 | for object in objects when object.$public 34 | @hoodie.store.update object.type, object.id, $public: false

    extend Hoodie

    Hoodie.extend 'user', Hoodie.User
    -------------------------------------------------------------------------------- /doc/core/config.html: -------------------------------------------------------------------------------- 1 | core/config
    src/core/config.coffee
    #

    Central Config API

    # 2 | 3 | class Hoodie.Config 4 |

    used as attribute name in localStorage

    type : '$config' 5 | id : 'hoodie'

    Constructor

    # 6 | constructor : (@hoodie, options = {}) ->

    memory cache

    @cache = {} 7 | 8 | @type = options.type if options.type 9 | @id = options.id if options.id 10 | 11 | @hoodie.store.find(@type, @id).done (obj) => @cache = obj 12 | 13 | @hoodie.on 'account:signedOut', @clear 14 | 15 |

    set

    #

    adds a configuration

    # 16 | set : (key, value) -> 17 | return if @cache[key] is value 18 | 19 | @cache[key] = value 20 | 21 | update = {} 22 | update[key] = value 23 | 24 | isSilent = key.charAt(0) is '_' 25 | @hoodie.store.update @type, @id, update, silent: isSilent 26 | 27 |

    get

    #

    receives a configuration

    # 28 | get : (key) -> 29 | @cache[key]

    clear

    #

    clears cache and removes object from store

    clear : => 30 | @cache = {} 31 | @hoodie.store.remove @type, @id 32 | 33 |

    remove

    34 | 35 |

    removes a configuration, is a simple alias for config.set(key, undefined)

    # 36 | remove : (key) -> 37 | @set(key, undefined)
    -------------------------------------------------------------------------------- /vendor/jasmine/lib/jasmine-core/jasmine.css: -------------------------------------------------------------------------------- 1 | body { background-color: #eeeeee; padding: 0; margin: 5px; overflow-y: scroll; } 2 | 3 | #HTMLReporter { font-size: 11px; font-family: Monaco, "Lucida Console", monospace; line-height: 14px; color: #333333; } 4 | #HTMLReporter a { text-decoration: none; } 5 | #HTMLReporter a:hover { text-decoration: underline; } 6 | #HTMLReporter p, #HTMLReporter h1, #HTMLReporter h2, #HTMLReporter h3, #HTMLReporter h4, #HTMLReporter h5, #HTMLReporter h6 { margin: 0; line-height: 14px; } 7 | #HTMLReporter .banner, #HTMLReporter .symbolSummary, #HTMLReporter .summary, #HTMLReporter .resultMessage, #HTMLReporter .specDetail .description, #HTMLReporter .alert .bar, #HTMLReporter .stackTrace { padding-left: 9px; padding-right: 9px; } 8 | #HTMLReporter #jasmine_content { position: fixed; right: 100%; } 9 | #HTMLReporter .version { color: #aaaaaa; } 10 | #HTMLReporter .banner { margin-top: 14px; } 11 | #HTMLReporter .duration { color: #aaaaaa; float: right; } 12 | #HTMLReporter .symbolSummary { overflow: hidden; *zoom: 1; margin: 14px 0; } 13 | #HTMLReporter .symbolSummary li { display: block; float: left; height: 7px; width: 14px; margin-bottom: 7px; font-size: 16px; } 14 | #HTMLReporter .symbolSummary li.passed { font-size: 14px; } 15 | #HTMLReporter .symbolSummary li.passed:before { color: #5e7d00; content: "\02022"; } 16 | #HTMLReporter .symbolSummary li.failed { line-height: 9px; } 17 | #HTMLReporter .symbolSummary li.failed:before { color: #b03911; content: "x"; font-weight: bold; margin-left: -1px; } 18 | #HTMLReporter .symbolSummary li.skipped { font-size: 14px; } 19 | #HTMLReporter .symbolSummary li.skipped:before { color: #bababa; content: "\02022"; } 20 | #HTMLReporter .symbolSummary li.pending { line-height: 11px; } 21 | #HTMLReporter .symbolSummary li.pending:before { color: #aaaaaa; content: "-"; } 22 | #HTMLReporter .exceptions { color: #fff; float: right; margin-top: 5px; margin-right: 5px; } 23 | #HTMLReporter .bar { line-height: 28px; font-size: 14px; display: block; color: #eee; } 24 | #HTMLReporter .runningAlert { background-color: #666666; } 25 | #HTMLReporter .skippedAlert { background-color: #aaaaaa; } 26 | #HTMLReporter .skippedAlert:first-child { background-color: #333333; } 27 | #HTMLReporter .skippedAlert:hover { text-decoration: none; color: white; text-decoration: underline; } 28 | #HTMLReporter .passingAlert { background-color: #a6b779; } 29 | #HTMLReporter .passingAlert:first-child { background-color: #5e7d00; } 30 | #HTMLReporter .failingAlert { background-color: #cf867e; } 31 | #HTMLReporter .failingAlert:first-child { background-color: #b03911; } 32 | #HTMLReporter .results { margin-top: 14px; } 33 | #HTMLReporter #details { display: none; } 34 | #HTMLReporter .resultsMenu, #HTMLReporter .resultsMenu a { background-color: #fff; color: #333333; } 35 | #HTMLReporter.showDetails .summaryMenuItem { font-weight: normal; text-decoration: inherit; } 36 | #HTMLReporter.showDetails .summaryMenuItem:hover { text-decoration: underline; } 37 | #HTMLReporter.showDetails .detailsMenuItem { font-weight: bold; text-decoration: underline; } 38 | #HTMLReporter.showDetails .summary { display: none; } 39 | #HTMLReporter.showDetails #details { display: block; } 40 | #HTMLReporter .summaryMenuItem { font-weight: bold; text-decoration: underline; } 41 | #HTMLReporter .summary { margin-top: 14px; } 42 | #HTMLReporter .summary .suite .suite, #HTMLReporter .summary .specSummary { margin-left: 14px; } 43 | #HTMLReporter .summary .specSummary.passed a { color: #5e7d00; } 44 | #HTMLReporter .summary .specSummary.failed a { color: #b03911; } 45 | #HTMLReporter .description + .suite { margin-top: 0; } 46 | #HTMLReporter .suite { margin-top: 14px; } 47 | #HTMLReporter .suite a { color: #333333; } 48 | #HTMLReporter #details .specDetail { margin-bottom: 28px; } 49 | #HTMLReporter #details .specDetail .description { display: block; color: white; background-color: #b03911; } 50 | #HTMLReporter .resultMessage { padding-top: 14px; color: #333333; } 51 | #HTMLReporter .resultMessage span.result { display: block; } 52 | #HTMLReporter .stackTrace { margin: 5px 0 0 0; max-height: 224px; overflow: auto; line-height: 18px; color: #666666; border: 1px solid #ddd; background: white; white-space: pre; } 53 | 54 | #TrivialReporter { padding: 8px 13px; position: absolute; top: 0; bottom: 0; left: 0; right: 0; overflow-y: scroll; background-color: white; font-family: "Helvetica Neue Light", "Lucida Grande", "Calibri", "Arial", sans-serif; /*.resultMessage {*/ /*white-space: pre;*/ /*}*/ } 55 | #TrivialReporter a:visited, #TrivialReporter a { color: #303; } 56 | #TrivialReporter a:hover, #TrivialReporter a:active { color: blue; } 57 | #TrivialReporter .run_spec { float: right; padding-right: 5px; font-size: .8em; text-decoration: none; } 58 | #TrivialReporter .banner { color: #303; background-color: #fef; padding: 5px; } 59 | #TrivialReporter .logo { float: left; font-size: 1.1em; padding-left: 5px; } 60 | #TrivialReporter .logo .version { font-size: .6em; padding-left: 1em; } 61 | #TrivialReporter .runner.running { background-color: yellow; } 62 | #TrivialReporter .options { text-align: right; font-size: .8em; } 63 | #TrivialReporter .suite { border: 1px outset gray; margin: 5px 0; padding-left: 1em; } 64 | #TrivialReporter .suite .suite { margin: 5px; } 65 | #TrivialReporter .suite.passed { background-color: #dfd; } 66 | #TrivialReporter .suite.failed { background-color: #fdd; } 67 | #TrivialReporter .spec { margin: 5px; padding-left: 1em; clear: both; } 68 | #TrivialReporter .spec.failed, #TrivialReporter .spec.passed, #TrivialReporter .spec.skipped { padding-bottom: 5px; border: 1px solid gray; } 69 | #TrivialReporter .spec.failed { background-color: #fbb; border-color: red; } 70 | #TrivialReporter .spec.passed { background-color: #bfb; border-color: green; } 71 | #TrivialReporter .spec.skipped { background-color: #bbb; } 72 | #TrivialReporter .messages { border-left: 1px dashed gray; padding-left: 1em; padding-right: 1em; } 73 | #TrivialReporter .passed { background-color: #cfc; display: none; } 74 | #TrivialReporter .failed { background-color: #fbb; } 75 | #TrivialReporter .skipped { color: #777; background-color: #eee; display: none; } 76 | #TrivialReporter .resultMessage span.result { display: block; line-height: 2em; color: black; } 77 | #TrivialReporter .resultMessage .mismatch { color: black; } 78 | #TrivialReporter .stackTrace { white-space: pre; font-size: .8em; margin-left: 10px; max-height: 5em; overflow: auto; border: 1px inset red; padding: 1em; background: #eef; } 79 | #TrivialReporter .finished-at { padding-left: 1em; font-size: .6em; } 80 | #TrivialReporter.show-passed .passed, #TrivialReporter.show-skipped .skipped { display: block; } 81 | #TrivialReporter #jasmine_content { position: fixed; right: 100%; } 82 | #TrivialReporter .runner { border: 1px solid gray; display: block; margin: 5px 0; padding: 2px 0 2px 10px; } 83 | -------------------------------------------------------------------------------- /vendor/jquery/jquery-migrate.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery Migrate v1.1.0 | (c) 2005, 2013 jQuery Foundation, Inc. and other contributors | jquery.org/license */ 2 | jQuery.migrateMute===void 0&&(jQuery.migrateMute=!0),function(e,t,n){"use strict";function r(n){o[n]||(o[n]=!0,e.migrateWarnings.push(n),t.console&&console.warn&&!e.migrateMute&&(console.warn("JQMIGRATE: "+n),e.migrateTrace&&console.trace&&console.trace()))}function a(t,a,o,i){if(Object.defineProperty)try{return Object.defineProperty(t,a,{configurable:!0,enumerable:!0,get:function(){return r(i),o},set:function(e){r(i),o=e}}),n}catch(s){}e._definePropertyBroken=!0,t[a]=o}var o={};e.migrateWarnings=[],!e.migrateMute&&t.console&&console.log&&console.log("JQMIGRATE: Logging is active"),e.migrateTrace===n&&(e.migrateTrace=!0),e.migrateReset=function(){o={},e.migrateWarnings.length=0},"BackCompat"===document.compatMode&&r("jQuery is not compatible with Quirks Mode");var i={},s=e.attr,u=e.attrHooks.value&&e.attrHooks.value.get||function(){return null},c=e.attrHooks.value&&e.attrHooks.value.set||function(){return n},l=/^(?:input|button)$/i,d=/^[238]$/,p=/^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i,f=/^(?:checked|selected)$/i;a(e,"attrFn",i,"jQuery.attrFn is deprecated"),e.attr=function(t,a,o,i){var u=a.toLowerCase(),c=t&&t.nodeType;return i&&4>s.length&&(r("jQuery.fn.attr( props, pass ) is deprecated"),t&&!d.test(c)&&e.isFunction(e.fn[a]))?e(t)[a](o):("type"===a&&o!==n&&l.test(t.nodeName)&&t.parentNode&&r("Can't change the 'type' of an input or button in IE 6/7/8"),!e.attrHooks[u]&&p.test(u)&&(e.attrHooks[u]={get:function(t,r){var a,o=e.prop(t,r);return o===!0||"boolean"!=typeof o&&(a=t.getAttributeNode(r))&&a.nodeValue!==!1?r.toLowerCase():n},set:function(t,n,r){var a;return n===!1?e.removeAttr(t,r):(a=e.propFix[r]||r,a in t&&(t[a]=!0),t.setAttribute(r,r.toLowerCase())),r}},f.test(u)&&r("jQuery.fn.attr('"+u+"') may use property instead of attribute")),s.call(e,t,a,o))},e.attrHooks.value={get:function(e,t){var n=(e.nodeName||"").toLowerCase();return"button"===n?u.apply(this,arguments):("input"!==n&&"option"!==n&&r("jQuery.fn.attr('value') no longer gets properties"),t in e?e.value:null)},set:function(e,t){var a=(e.nodeName||"").toLowerCase();return"button"===a?c.apply(this,arguments):("input"!==a&&"option"!==a&&r("jQuery.fn.attr('value', val) no longer sets properties"),e.value=t,n)}};var g,h,v=e.fn.init,m=e.parseJSON,y=/^(?:[^<]*(<[\w\W]+>)[^>]*|#([\w\-]*))$/;e.fn.init=function(t,n,a){var o;return t&&"string"==typeof t&&!e.isPlainObject(n)&&(o=y.exec(t))&&o[1]&&("<"!==t.charAt(0)&&r("$(html) HTML strings must start with '<' character"),n&&n.context&&(n=n.context),e.parseHTML)?v.call(this,e.parseHTML(e.trim(t),n,!0),n,a):v.apply(this,arguments)},e.fn.init.prototype=e.fn,e.parseJSON=function(e){return e||null===e?m.apply(this,arguments):(r("jQuery.parseJSON requires a valid JSON string"),null)},e.uaMatch=function(e){e=e.toLowerCase();var t=/(chrome)[ \/]([\w.]+)/.exec(e)||/(webkit)[ \/]([\w.]+)/.exec(e)||/(opera)(?:.*version|)[ \/]([\w.]+)/.exec(e)||/(msie) ([\w.]+)/.exec(e)||0>e.indexOf("compatible")&&/(mozilla)(?:.*? rv:([\w.]+)|)/.exec(e)||[];return{browser:t[1]||"",version:t[2]||"0"}},g=e.uaMatch(navigator.userAgent),h={},g.browser&&(h[g.browser]=!0,h.version=g.version),h.chrome?h.webkit=!0:h.webkit&&(h.safari=!0),e.browser=h,a(e,"browser",h,"jQuery.browser is deprecated"),e.sub=function(){function t(e,n){return new t.fn.init(e,n)}e.extend(!0,t,this),t.superclass=this,t.fn=t.prototype=this(),t.fn.constructor=t,t.sub=this.sub,t.fn.init=function(r,a){return a&&a instanceof e&&!(a instanceof t)&&(a=t(a)),e.fn.init.call(this,r,a,n)},t.fn.init.prototype=t.fn;var n=t(document);return r("jQuery.sub() is deprecated"),t};var b=e.fn.data;e.fn.data=function(t){var a,o,i=this[0];return!i||"events"!==t||1!==arguments.length||(a=e.data(i,t),o=e._data(i,t),a!==n&&a!==o||o===n)?b.apply(this,arguments):(r("Use of jQuery.fn.data('events') is deprecated"),o)};var j=/\/(java|ecma)script/i,w=e.fn.andSelf||e.fn.addBack;e.fn.andSelf=function(){return r("jQuery.fn.andSelf() replaced by jQuery.fn.addBack()"),w.apply(this,arguments)},e.clean||(e.clean=function(t,a,o,i){a=a||document,a=!a.nodeType&&a[0]||a,a=a.ownerDocument||a,r("jQuery.clean() is deprecated");var s,u,c,l,d=[];if(e.merge(d,e.buildFragment(t,a).childNodes),o)for(c=function(e){return!e.type||j.test(e.type)?i?i.push(e.parentNode?e.parentNode.removeChild(e):e):o.appendChild(e):n},s=0;null!=(u=d[s]);s++)e.nodeName(u,"script")&&c(u)||(o.appendChild(u),u.getElementsByTagName!==n&&(l=e.grep(e.merge([],u.getElementsByTagName("script")),c),d.splice.apply(d,[s+1,0].concat(l)),s+=l.length));return d});var Q=e.event.add,x=e.event.remove,k=e.event.trigger,N=e.fn.toggle,C=e.fn.live,T=e.fn.die,M="ajaxStart|ajaxStop|ajaxSend|ajaxComplete|ajaxError|ajaxSuccess",S=RegExp("\\b(?:"+M+")\\b"),H=/(?:^|\s)hover(\.\S+|)\b/,A=function(t){return"string"!=typeof t||e.event.special.hover?t:(H.test(t)&&r("'hover' pseudo-event is deprecated, use 'mouseenter mouseleave'"),t&&t.replace(H,"mouseenter$1 mouseleave$1"))};e.event.props&&"attrChange"!==e.event.props[0]&&e.event.props.unshift("attrChange","attrName","relatedNode","srcElement"),e.event.dispatch&&a(e.event,"handle",e.event.dispatch,"jQuery.event.handle is undocumented and deprecated"),e.event.add=function(e,t,n,a,o){e!==document&&S.test(t)&&r("AJAX events should be attached to document: "+t),Q.call(this,e,A(t||""),n,a,o)},e.event.remove=function(e,t,n,r,a){x.call(this,e,A(t)||"",n,r,a)},e.fn.error=function(){var e=Array.prototype.slice.call(arguments,0);return r("jQuery.fn.error() is deprecated"),e.splice(0,0,"error"),arguments.length?this.bind.apply(this,e):(this.triggerHandler.apply(this,e),this)},e.fn.toggle=function(t,n){if(!e.isFunction(t)||!e.isFunction(n))return N.apply(this,arguments);r("jQuery.fn.toggle(handler, handler...) is deprecated");var a=arguments,o=t.guid||e.guid++,i=0,s=function(n){var r=(e._data(this,"lastToggle"+t.guid)||0)%i;return e._data(this,"lastToggle"+t.guid,r+1),n.preventDefault(),a[r].apply(this,arguments)||!1};for(s.guid=o;a.length>i;)a[i++].guid=o;return this.click(s)},e.fn.live=function(t,n,a){return r("jQuery.fn.live() is deprecated"),C?C.apply(this,arguments):(e(this.context).on(t,this.selector,n,a),this)},e.fn.die=function(t,n){return r("jQuery.fn.die() is deprecated"),T?T.apply(this,arguments):(e(this.context).off(t,this.selector||"**",n),this)},e.event.trigger=function(e,t,n,a){return!n&!S.test(e)&&r("Global events are undocumented and deprecated"),k.call(this,e,t,n||document,a)},e.each(M.split("|"),function(t,n){e.event.special[n]={setup:function(){var t=this;return t!==document&&(e.event.add(document,n+"."+e.guid,function(){e.event.trigger(n,null,t,!0)}),e._data(this,n,e.guid++)),!1},teardown:function(){return this!==document&&e.event.remove(document,n+"."+e._data(this,n)),!1}}})}(jQuery,window); 3 | //@ sourceMappingURL=dist/jquery-migrate.min.map -------------------------------------------------------------------------------- /src/core/store.coffee: -------------------------------------------------------------------------------- 1 | # Store 2 | # ============ 3 | 4 | # This class defines the API that other Stores have to implement to assure a 5 | # coherent API. 6 | # 7 | # It also implements some validations and functionality that is the same across 8 | # store impnementations 9 | 10 | class Hoodie.Store 11 | 12 | 13 | # ## Constructor 14 | 15 | # set store.hoodie instance variable 16 | constructor : (@hoodie) -> 17 | 18 | 19 | # ## Save 20 | 21 | # creates or replaces an an eventually existing object in the store 22 | # with same type & id. 23 | # 24 | # When id is undefined, it gets generated and a new object gets saved 25 | # 26 | # example usage: 27 | # 28 | # store.save('car', undefined, {color: 'red'}) 29 | # store.save('car', 'abc4567', {color: 'red'}) 30 | save : (type, id, object, options = {}) -> 31 | defer = @hoodie.defer() 32 | 33 | unless typeof object is 'object' 34 | defer.reject Hoodie.Errors.INVALID_ARGUMENTS "object is #{typeof object}" 35 | return defer.promise() 36 | 37 | # validations 38 | if id and not @_isValidId id 39 | return defer.reject( Hoodie.Errors.INVALID_KEY id: id ).promise() 40 | 41 | unless @_isValidType type 42 | return defer.reject( Hoodie.Errors.INVALID_KEY type: type ).promise() 43 | 44 | return defer 45 | 46 | 47 | # ## Add 48 | 49 | # `.add` is an alias for `.save`, with the difference that there is no id argument. 50 | # Internally it simply calls `.save(type, undefined, object). 51 | add : (type, object = {}, options = {}) -> 52 | @save type, object.id, object 53 | 54 | 55 | # ## Update 56 | 57 | # In contrast to `.save`, the `.update` method does not replace the stored object, 58 | # but only changes the passed attributes of an exsting object, if it exists 59 | # 60 | # both a hash of key/values or a function that applies the update to the passed 61 | # object can be passed. 62 | # 63 | # example usage 64 | # 65 | # hoodie.store.update('car', 'abc4567', {sold: true}) 66 | # hoodie.store.update('car', 'abc4567', function(obj) { obj.sold = true }) 67 | update : (type, id, objectUpdate, options) -> 68 | defer = @hoodie.defer() 69 | 70 | _loadPromise = @find(type, id).pipe (currentObj) => 71 | 72 | # normalize input 73 | newObj = $.extend(true, {}, currentObj) 74 | objectUpdate = objectUpdate( newObj ) if typeof objectUpdate is 'function' 75 | 76 | return defer.resolve currentObj unless objectUpdate 77 | 78 | # check if something changed 79 | changedProperties = for key, value of objectUpdate when currentObj[key] isnt value 80 | # workaround for undefined values, as $.extend ignores these 81 | newObj[key] = value 82 | key 83 | 84 | return defer.resolve newObj unless changedProperties.length or options 85 | 86 | # apply update 87 | @save(type, id, newObj, options).then defer.resolve, defer.reject 88 | 89 | # if not found, add it 90 | _loadPromise.fail => 91 | @save(type, id, objectUpdate, options).then defer.resolve, defer.reject 92 | 93 | defer.promise() 94 | 95 | 96 | # ## updateAll 97 | 98 | # update all objects in the store, can be optionally filtered by a function 99 | # As an alternative, an array of objects can be passed 100 | # 101 | # example usage 102 | # 103 | # hoodie.store.updateAll() 104 | updateAll : (filterOrObjects, objectUpdate, options = {}) -> 105 | 106 | # normalize the input: make sure we have all objects 107 | switch true 108 | when typeof filterOrObjects is 'string' 109 | promise = @findAll filterOrObjects 110 | when @hoodie.isPromise(filterOrObjects) 111 | promise = filterOrObjects 112 | when $.isArray filterOrObjects 113 | promise = @hoodie.defer().resolve( filterOrObjects ).promise() 114 | else # e.g. null, update all 115 | promise = @findAll() 116 | 117 | promise.pipe (objects) => 118 | # now we update all objects one by one and return a promise 119 | # that will be resolved once all updates have been finished 120 | defer = @hoodie.defer() 121 | 122 | objects = [objects] unless $.isArray objects 123 | _updatePromises = for object in objects 124 | @update(object.type, object.id, objectUpdate, options) 125 | $.when.apply(null, _updatePromises).then defer.resolve 126 | 127 | return defer.promise() 128 | 129 | 130 | # ## find 131 | 132 | # loads one object from Store, specified by `type` and `id` 133 | # 134 | # example usage: 135 | # 136 | # store.find('car', 'abc4567') 137 | find : (type, id) -> 138 | defer = @hoodie.defer() 139 | 140 | unless typeof type is 'string' and typeof id is 'string' 141 | return defer.reject( Hoodie.Errors.INVALID_ARGUMENTS "type & id are required" ).promise() 142 | 143 | return defer 144 | 145 | 146 | # ## find or add 147 | 148 | # 1. Try to find a share by given id 149 | # 2. If share could be found, return it 150 | # 3. If not, add one and return it. 151 | findOrAdd : (type, id, attributes = {}) -> 152 | defer = @hoodie.defer() 153 | @find(type, id) 154 | .done( defer.resolve ) 155 | .fail => 156 | newAttributes = $.extend true, id: id, attributes 157 | @add(type, newAttributes).then defer.resolve, defer.reject 158 | 159 | return defer.promise() 160 | 161 | 162 | # ## findAll 163 | 164 | # returns all objects from store. 165 | # Can be optionally filtered by a type or a function 166 | findAll : -> @hoodie.defer() 167 | 168 | 169 | # ## Destroy 170 | 171 | # Destroyes one object specified by `type` and `id`. 172 | # 173 | # when object has been synced before, mark it as deleted. 174 | # Otherwise remove it from Store. 175 | remove : (type, id, options = {}) -> 176 | defer = @hoodie.defer() 177 | 178 | unless typeof type is 'string' and typeof id is 'string' 179 | return defer.reject( Hoodie.Errors.INVALID_ARGUMENTS "type & id are required" ).promise() 180 | 181 | return defer 182 | 183 | 184 | # ## removeAll 185 | 186 | # Destroyes all objects. Can be filtered by a type 187 | removeAll : (type, options = {}) -> 188 | @findAll(type).pipe (objects) => 189 | @remove(object.type, object.id, options) for object in objects 190 | 191 | 192 | # ## Private 193 | 194 | # 195 | _now : -> new Date 196 | 197 | # / not allowed for id 198 | _isValidId : (key) -> 199 | # /^[a-z0-9\-]+$/.test key 200 | /^[^\/]+$/.test key 201 | 202 | # / not allowed for type 203 | _isValidType : (key) -> 204 | # /^[a-z$][a-z0-9]+$/.test key 205 | /^[^\/]+$/.test key -------------------------------------------------------------------------------- /src/extensions/share.coffee: -------------------------------------------------------------------------------- 1 | # Share Module 2 | # ============== 3 | 4 | # When a share gets created, a $share doc gets stored and synched to the user's 5 | # database. From there the $share worker handles the rest: 6 | # 7 | # * creating a share database 8 | # * creating a share user if a password is used (to be done) 9 | # * handling the replications 10 | # 11 | # The worker updates the $share doc status, which gets synched back to the 12 | # frontend. When the user deletes the $share doc, the worker removes the 13 | # database, the user and all replications 14 | # 15 | # 16 | # API 17 | # ----- 18 | # 19 | # // returns a share instance 20 | # // with share.id set to 'share_id' 21 | # hoodie.share('share_id') 22 | # 23 | # // the rest of the API is a standard store API, with the 24 | # // difference that no type has to be set and the returned 25 | # // promises are resolved with share instances instead of 26 | # // simple objects 27 | # hoodie.share.add(attributes) 28 | # hoodie.share.find('share_id') 29 | # hoodie.share.findAll() 30 | # hoodie.share.findOrAdd(id, attributes) 31 | # hoodie.share.save(id, attributes) 32 | # hoodie.share.update(id, changed_attributes) 33 | # hoodie.share.updateAll(changed_attributes) 34 | # hoodie.share.remove(id) 35 | # hoodie.share.removeAll() 36 | 37 | # 38 | class Hoodie.Share 39 | 40 | 41 | # Constructor 42 | # ------------- 43 | 44 | # the constructor returns a function, so it can be called 45 | # like this: hoodie.share('share_id') 46 | # 47 | # The rest of the API is available as usual. 48 | constructor : (@hoodie) -> 49 | 50 | # set pointer to Hoodie.ShareInstance 51 | @instance = Hoodie.ShareInstance 52 | 53 | # return custom api which allows direct call 54 | api = @_open 55 | $.extend api, this 56 | 57 | # extend hodie.store promise API 58 | @hoodie.store.decoratePromises 59 | shareAt : @_storeShareAt 60 | unshareAt : @_storeUnshareAt 61 | unshare : @_storeUnshare 62 | share : @_storeShare 63 | 64 | `return api` 65 | 66 | 67 | # add 68 | # -------- 69 | 70 | # creates a new share and returns it 71 | # 72 | add : (options = {}) -> 73 | @hoodie.store.add('$share', @_filterShareOptions(options)).pipe (object) => 74 | unless @hoodie.account.hasAccount() 75 | @hoodie.account.anonymousSignUp() 76 | 77 | new @instance @hoodie, object 78 | 79 | 80 | # find 81 | # ------ 82 | 83 | # find an existing share 84 | # 85 | find : (id) -> 86 | @hoodie.store.find('$share', id).pipe (object) => 87 | new @instance @hoodie, object 88 | 89 | 90 | # findAll 91 | # --------- 92 | 93 | # find all my existing shares 94 | # 95 | findAll : -> 96 | @hoodie.store.findAll('$share').pipe (objects) => 97 | new @instance @hoodie, obj for obj in objects 98 | 99 | 100 | # findOrAdd 101 | # -------------- 102 | 103 | # find or add a new share 104 | # 105 | findOrAdd : (id, options) -> 106 | @hoodie.store.findOrAdd('$share', id, @_filterShareOptions options).pipe (object) => 107 | unless @hoodie.account.hasAccount() 108 | @hoodie.account.anonymousSignUp() 109 | 110 | new @instance @hoodie, object 111 | 112 | 113 | # save 114 | # ------ 115 | 116 | # add or overwrite a share 117 | # 118 | save : (id, options) -> 119 | @hoodie.store.save('$share', id, @_filterShareOptions options).pipe (object) => 120 | new @instance @hoodie, object 121 | 122 | 123 | # update 124 | # -------- 125 | 126 | # add or overwrite a share 127 | # 128 | update : (id, changed_options) -> 129 | @hoodie.store.update('$share', id, @_filterShareOptions changed_options).pipe (object) => 130 | new @instance @hoodie, object 131 | 132 | 133 | # updateAll 134 | # ----------- 135 | 136 | # update all my existing shares 137 | # 138 | updateAll : ( changed_options ) -> 139 | @hoodie.store.updateAll('$share', @_filterShareOptions changed_options).pipe (objects) => 140 | new @instance @hoodie, obj for obj in objects 141 | 142 | 143 | # remove 144 | # --------- 145 | 146 | # deletes an existing share 147 | # 148 | remove : (id) -> 149 | @hoodie.store.findAll( (obj) -> obj.$shares[id] ).unshareAt( id ) 150 | @hoodie.store.remove('$share', id) 151 | 152 | 153 | # removeAll 154 | # ------------ 155 | 156 | # delete all existing shares 157 | # 158 | removeAll : () -> 159 | @hoodie.store.findAll( (obj) -> obj.$shares ).unshare() 160 | @hoodie.store.removeAll('$share') 161 | 162 | 163 | # Private 164 | # --------- 165 | 166 | _allowedOptions: ["id", "access", "createdBy"] 167 | 168 | # ### filter share options 169 | # 170 | _filterShareOptions: (options = {}) -> 171 | filteredOptions = {} 172 | for option in @_allowedOptions when options.hasOwnProperty option 173 | filteredOptions[option] = options[option] 174 | filteredOptions 175 | 176 | # ### open 177 | # 178 | # opens a a remote share store, returns a Hoodie.Remote instance 179 | _open : (shareId, options = {}) => 180 | $.extend options, {id: shareId} 181 | new @instance @hoodie, options 182 | 183 | 184 | # hoodie.store decorations 185 | # -------------------------- 186 | # 187 | # hoodie.store decorations add custom methods to promises returned 188 | # by hoodie.store methods like find, add or update. All methods return 189 | # methods again that will be executed in the scope of the promise, but 190 | # with access to the current hoodie instance 191 | 192 | # shareAt 193 | # 194 | _storeShareAt : (shareId) -> 195 | @pipe (objects) => 196 | 197 | updateObject = (object) => 198 | @hoodie.store.update object.type, object.id, $sharedAt: shareId 199 | return object 200 | 201 | if $.isArray objects 202 | updateObject(object) for object in objects 203 | else 204 | updateObject(objects) 205 | 206 | 207 | # unshareAt 208 | # 209 | _storeUnshareAt : (shareId) -> 210 | @pipe (objects) => 211 | 212 | updateObject = (object) => 213 | return object unless object.$sharedAt is shareId 214 | 215 | @hoodie.store.update object.type, object.id, $unshared: true 216 | return object 217 | 218 | if $.isArray objects 219 | updateObject(object) for object in objects 220 | else 221 | updateObject(objects) 222 | 223 | # unshare 224 | # 225 | _storeUnshare : () -> 226 | @pipe (objects) => 227 | 228 | updateObject = (object) => 229 | return object unless object.$sharedAt 230 | 231 | @hoodie.store.update object.type, object.id, $unshared: true 232 | return object 233 | 234 | if $.isArray objects 235 | updateObject(object) for object in objects 236 | else 237 | updateObject(objects) 238 | 239 | # share 240 | # 241 | _storeShare : (properties) -> 242 | 243 | @pipe (objects) => 244 | 245 | @hoodie.share.add().pipe (newShare) => 246 | 247 | updateObject = (object) => 248 | @hoodie.store.update object.type, object.id, $sharedAt: newShare.id 249 | return object 250 | 251 | value = if $.isArray objects 252 | updateObject(object) for object in objects 253 | else 254 | updateObject(objects) 255 | 256 | return @hoodie.defer().resolve(value, newShare).promise() 257 | 258 | 259 | 260 | # extend Hoodie 261 | Hoodie.extend 'share', Hoodie.Share -------------------------------------------------------------------------------- /wishlist/share.js: -------------------------------------------------------------------------------- 1 | // Share 2 | // ======= 3 | // 4 | // a share is like a store, give I'm permitted to access / modify the share 5 | // I can add / find / update / remove objects 6 | 7 | 8 | // Share Module API 9 | // ------------------ 10 | 11 | // the hoodie.share module provides a store, 12 | // which is special in two ways: 13 | // 14 | // 1. no type can be passed 15 | // 2. returned objects are not objects, but share instances 16 | 17 | // hoodie.share Module API: 18 | hoodie.share.find() 19 | hoodie.share.findAll() 20 | hoodie.share.findOrAdd() 21 | hoodie.share.add() 22 | hoodie.share.update() 23 | hoodie.share.updateAll() 24 | hoodie.share.remove() 25 | hoodie.share.removeAll() 26 | 27 | // on top, it allows a direct call: 28 | // that opens a share from remote and exposes a store API 29 | // to directly interact with the store. 30 | share = hoodie.share('shareId') 31 | 32 | 33 | // Share Instance API 34 | // -------------------- 35 | 36 | // a share provides a store for its objects 37 | // all these methods make direct calls to the 38 | // remote share store. If share is a new instance, 39 | // it gets published automtically 40 | share.store.find() 41 | share.store.findAll() 42 | share.store.findOrAdd() 43 | share.store.add() 44 | share.store.update() 45 | share.store.updateAll() 46 | share.store.remove() 47 | share.store.removeAll() 48 | 49 | // grant / revoke access 50 | share.grantAccess() // everybody can read 51 | share.revokeAccess() // nobody but me has access 52 | share.grantWriteAccess() // everybody can write 53 | share.revokeWriteAccess() // nobody but me can wirte 54 | share.grantAccess("joe@example.com") 55 | share.grantWriteAccess(["lisa@example.com", "judy@example.com"]) 56 | share.revokeAccess("lisa@example.com") 57 | 58 | 59 | // Sharing objects from my store 60 | // ------------------------------- 61 | 62 | // the hoodie.share module also extends the hoodie.store 63 | // api with two methods: share and unshare 64 | // 65 | // compare to [store.publish / store.unpublish](public_user_stores.html) 66 | hoodie.store.find('task', '123').share() 67 | hoodie.store.find('task', '123').shareAt( share.id) 68 | hoodie.store.find('task', '123').unshare() 69 | hoodie.store.find('task', '123').unshareAt( share.id ) 70 | 71 | // When executing `shareAt`, internally we execute 72 | // `hoodie.share.findOrAdd(id, options)`. That means 73 | // 74 | // 1. I can directly share objects at a self defined id without 75 | // manually creating the share upfront. 76 | // 2. I can share objects at an existing share (mine or somebody 77 | // else's) 78 | // 79 | // So the following code shares all my car objects at "favoritecars" 80 | // wich is a shared stor that might or might not exist yet 81 | hoodie.store.findAll('car').shareAt( "favoritecars" ) 82 | 83 | // In case something goes wrong, for example the "favoritecars" share 84 | // exists but I don't have write permissions on it, an error will 85 | // be triggered, but asynchroniously. 86 | hoodie.share.on('error:favoritecars', handleShareError) 87 | 88 | 89 | // example 90 | // --------- 91 | 92 | // You can share one ore multiple objects right away with this 93 | // piece of coude 94 | var todolist = { 95 | title: 'shopping list', 96 | tasks: [ 97 | {title: 'nutella'}, 98 | {title: 'bread'}, 99 | {title: 'milk'} 100 | ] 101 | } 102 | hoodie.store.add('todolist', todolist).share() 103 | .done( function(todolist, share) { 104 | /* now others can access the shared directly with 105 | hoodie.share( share.id ).store.findAll() */ 106 | }) 107 | 108 | 109 | // subsribing to shares by others 110 | // -------------------------------- 111 | 112 | // to subscribe to a share, use the following code: 113 | hoodie.store.share('secretShareId').subscribe() 114 | 115 | // a subscription creates internally also a $share object, 116 | // so I can treat it in the same way. When subscribing to 117 | // a share, the workers will setup continuous replications 118 | // to pull and — if I have write access — to push changes. 119 | // Objects that get pulled from other users' shares will 120 | // get be set with a {$shares: {secretShareId: true} } 121 | 122 | 123 | // Use cases 124 | // ----------- 125 | // 126 | // 1. Public Share 127 | // 2. Private Share 128 | // 5. Read only Share 129 | // 6. Collaborative Shares 130 | // 7. Public, password protected shares 131 | // 8. Listen to events in Shares 132 | // 9. Subscribe to other users' shares 133 | 134 | 135 | // ### Usecase 1: Public Share 136 | 137 | // Let's say I've a todolist that I want to share 138 | // publicly with others with an secret URL. First we add the todolist 139 | // (by passing an object with the respective type & id) and then 140 | // the todolist will be available to others at the secret URL 141 | // 142 | hoodie.store.add('todolist', todolist).share() 143 | .done( function(todolist, share) { 144 | share.grantReadAccess() 145 | share_url = "http://mytodoapp.com/shared/" + share.id; 146 | alert("Share your todolist at " + share_url); 147 | }) 148 | 149 | 150 | // ### Usecase 2: Private Share 151 | 152 | // Let's say I've another todolist that I want to share only 153 | // with my collegues aj@example.com and bj@example.com. I want the todolist to 154 | // to be accessible for AJ, BJ and myself only. 155 | // 156 | hoodie.store.add('todolist', todolist).share() 157 | .done( function(todolist, share) { 158 | share.grantReadAccess(["aj@example.com", "bj@example.com"]) 159 | share_url = "http://mytodoapp.com/shared/" + share.id; 160 | alert("Share your todolist at " + share_url); 161 | }) 162 | 163 | 164 | 165 | // ### Usecase 5: Read only Share 166 | 167 | // If you want to prevent other users to make changes to your shared objects, 168 | // grant read access. If another user will try to push to 169 | // `hoodie.share("share_id")`, it will be rejected. 170 | // 171 | hoodie.share.add() 172 | .done( function(share) { 173 | share.grantReadAccess() 174 | }) 175 | 176 | 177 | // ### Usecase 6: Collaborative Shares 178 | 179 | // If you want to invite others to collaborate on your objects, you need 180 | // to grant write access. Then all users knowing the 181 | // share.id will be able to push changes. 182 | // 183 | hoodie.share.add() 184 | .done( function(share) { 185 | share.grantWriteAccess() 186 | }) 187 | 188 | 189 | // ### Usecase 7: Public, password protected shares 190 | 191 | // I can optionally assign a password to a share that needs to be provided by 192 | // others when trying to accessing it: 193 | hoodie.share.add( { 194 | id : "mytodolist123", 195 | password : "secret" 196 | }).done( function(share) {} ) 197 | 198 | // If other users want to access your share, they need to passt the password 199 | // as option 200 | hoodie.share( "mytodolist123", {password: "secret"} ) 201 | .store.findAll( function(objects) { 202 | alert("welcome to my todolist!"); 203 | }); 204 | 205 | 206 | // ### Usecase 8: Listen to events in Shares 207 | 208 | // I can open a share and listen to changes of its containing objects 209 | // 210 | hoodie.share('shared_id').on('store:changed', function(object) { /*...*/ }); 211 | hoodie.share('shared_id').on('store:changed:type', function(object) { /*...*/ }); 212 | hoodie.share('shared_id').on('store:created', function(object) { /*...*/ }); 213 | hoodie.share('shared_id').on('store:created:type', function(object) { /*...*/ }); 214 | hoodie.share('shared_id').on('store:updated', function(object) { /*...*/ }); 215 | hoodie.share('shared_id').on('store:updated:type', function(object) { /*...*/ }); 216 | hoodie.share('shared_id').on('store:destroyed', function(object) { /*...*/ }); 217 | hoodie.share('shared_id').on('store:destroyed:type', function(object) { /*...*/ }); 218 | 219 | // I can also listen to errors happening to my own shares, e.g. if I 220 | // try to push updates to a share without having write permissions 221 | hoodie.share.on('error', function(error, share) { 222 | 223 | }) -------------------------------------------------------------------------------- /src/extensions/share_instance.coffee: -------------------------------------------------------------------------------- 1 | # Share Instance 2 | # ================ 3 | 4 | # A share instance provides an API to interact with a 5 | # share. It's extending the default Remote Store by methods 6 | # to grant or revoke read / write access. 7 | # 8 | # By default, a share is only accessible to me. If I want 9 | # it to share it, I explicatly need to grant access 10 | # by calling `share.grantReadAccess()`. I can also grant 11 | # access to only specific users by passing an array: 12 | # `share.grantReadAccess(['joe','lisa'])` 13 | # 14 | # It's plannend to secure a public share with a password, 15 | # but this feature is not yet implemented. 16 | # 17 | # To subscribe to a share created by somebody else, run 18 | # this code: `hoodie.share('shareId').subscribe()`. 19 | class Hoodie.ShareInstance extends Hoodie.Remote 20 | 21 | 22 | # default values 23 | # ---------------- 24 | 25 | # shares are not accessible to others by default. 26 | access: false 27 | 28 | 29 | # constructor 30 | # ------------- 31 | 32 | # initializes a new share 33 | # 34 | constructor : (@hoodie, options = {}) -> 35 | 36 | # make sure that we have an id 37 | @id = options.id or @hoodie.uuid() 38 | 39 | # set name from id 40 | @name = "share/#{@id}" 41 | 42 | # set prefix from name 43 | @prefix = @name 44 | 45 | # set options 46 | $.extend this, options 47 | 48 | super 49 | 50 | 51 | # subscribe 52 | # --------- 53 | 54 | # 55 | subscribe : -> 56 | @request('GET', '/_security') 57 | .pipe @_handleSecurityResponse 58 | 59 | 60 | # unsubscribe 61 | # ----------- 62 | 63 | # 64 | unsubscribe : -> 65 | @hoodie.share.remove( @id ) 66 | @hoodie.store.removeAll( @_objectBelongsToMe, local : true ) 67 | return this 68 | 69 | 70 | # grant read access 71 | # ------------------- 72 | # 73 | # grant read access to the share. If no users passed, 74 | # everybody can read the share objects. If one or multiple 75 | # users passed, only these users get read access. 76 | # 77 | # examples: 78 | # 79 | # share.grantReadAccess() 80 | # share.grantReadAccess('joe@example.com') 81 | # share.grantReadAccess(['joe@example.com', 'lisa@example.com']) 82 | grantReadAccess : (users) -> 83 | if @access is true or @access.read is true 84 | return @hoodie.resolveWith this 85 | 86 | users = [users] if typeof users is 'string' 87 | if @access is false or @access.read is false 88 | if @access.read? 89 | @access.read = users or true 90 | else 91 | @access = users or true 92 | 93 | if users 94 | currentUsers = @access.read or @access 95 | for user in users 96 | currentUsers.push(user) if currentUsers.indexOf(user) is -1 97 | 98 | if @access.read? 99 | @access.read = currentUsers 100 | else 101 | @access = currentUsers 102 | 103 | else 104 | if @access.read? 105 | @access.read = true 106 | else 107 | @access = true 108 | 109 | @hoodie.share.update(@id, access: @access) 110 | 111 | 112 | # revoke read access 113 | # -------------------- 114 | # 115 | # revoke read access to the share. If one or multiple 116 | # users passed, only these users' access gets revoked. 117 | # Revoking reading access always includes revoking write 118 | # access as well. 119 | # 120 | # examples: 121 | # 122 | # share.revokeReadAccess() 123 | # share.revokeReadAccess('joe@example.com') 124 | # share.revokeReadAccess(['joe@example.com', 'lisa@example.com']) 125 | revokeReadAccess : (users) -> 126 | @revokeWriteAccess(users) 127 | 128 | if @access is false or @access.read is false 129 | return @hoodie.resolveWith this 130 | 131 | if users 132 | if @access is true or @access.read is true 133 | return @hoodie.rejectWith this 134 | 135 | users = [users] if typeof users is 'string' 136 | 137 | currentUsers = @access.read or @access 138 | changed = false 139 | 140 | for user in users 141 | idx = currentUsers.indexOf(user) 142 | if idx != -1 143 | currentUsers.splice(idx, 1) 144 | changed = true 145 | 146 | unless changed 147 | return @hoodie.resolveWith this 148 | 149 | currentUsers = false if currentUsers.length is 0 150 | 151 | 152 | 153 | if @access.read? 154 | @access.read = currentUsers 155 | else 156 | @access = currentUsers 157 | 158 | else 159 | @access = false 160 | 161 | @hoodie.share.update(@id, access: @access) 162 | 163 | 164 | # grant write access 165 | # -------------------- 166 | # 167 | # grant write access to the share. If no users passed, 168 | # everybody can edit the share objects. If one or multiple 169 | # users passed, only these users get write access. Granting 170 | # writing reads always also includes reading rights. 171 | # 172 | # examples: 173 | # 174 | # share.grantWriteAccess() 175 | # share.grantWriteAccess('joe@example.com') 176 | # share.grantWriteAccess(['joe@example.com', 'lisa@example.com']) 177 | grantWriteAccess : (users) -> 178 | @grantReadAccess(users) 179 | unless @access.read? 180 | @access = read: @access 181 | 182 | if @access.write is true 183 | return @hoodie.resolveWith this 184 | 185 | if users 186 | users = [users] if typeof users is 'string' 187 | @access.write = users 188 | else 189 | @access.write = true 190 | 191 | @hoodie.share.update(@id, access: @access) 192 | 193 | 194 | # revoke write access 195 | # -------------------- 196 | # 197 | # revoke write access to the share. If one or multiple 198 | # users passed, only these users' write access gets revoked. 199 | # 200 | # examples: 201 | # 202 | # share.revokeWriteAccess() 203 | # share.revokeWriteAccess('joe@example.com') 204 | # share.revokeWriteAccess(['joe@example.com', 'lisa@example.com']) 205 | revokeWriteAccess : (users) -> 206 | unless @access.write? 207 | return @hoodie.resolveWith this 208 | 209 | if users 210 | if typeof @access.write is 'boolean' 211 | return @hoodie.rejectWith this 212 | 213 | users = [users] if typeof users is 'string' 214 | for user in users 215 | idx = @access.write.indexOf(user) 216 | if idx != -1 217 | @access.write.splice(idx, 1) 218 | 219 | if @access.write.length is 0 220 | @access = @access.read 221 | 222 | else 223 | @access = @access.read 224 | 225 | @hoodie.share.update(@id, access: @access) 226 | 227 | 228 | # PRIVATE 229 | # --------- 230 | 231 | # 232 | _objectBelongsToMe : (object) => 233 | object.$sharedAt is @id 234 | 235 | # 236 | _handleSecurityResponse : (security) => 237 | access = @_parseSecurity security 238 | createdBy = '$subscription' 239 | @hoodie.share.findOrAdd( @id, {access, createdBy} ) 240 | 241 | # a db _security response looks like this: 242 | # 243 | # { 244 | # members: { 245 | # names: [], 246 | # roles: ["1ihhzfy"] 247 | # }, 248 | # writers: { 249 | # names: [], 250 | # roles: ["1ihhzfy"] 251 | # } 252 | # } 253 | # 254 | # we want to turn it into 255 | # 256 | # {read: true, write: true} 257 | # 258 | # given that users ownerHash is "1ihhzfy" 259 | _parseSecurity : (security) -> 260 | read = security.members?.roles 261 | write = security.writers?.roles 262 | 263 | access = {} 264 | if read? 265 | access.read = read is true or read.length is 0 266 | access.read = -1 isnt read.indexOf(@hoodie.account.ownerHash) if read.length 267 | if write? 268 | access.write = write is true or write.length is 0 269 | access.write = -1 isnt write.indexOf(@hoodie.account.ownerHash) if write.length 270 | 271 | access -------------------------------------------------------------------------------- /test/specs/hoodie.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "Hoodie", -> 2 | beforeEach -> 3 | @hoodie = new Hoodie 'http://couch.example.com' 4 | spyOn($, "ajax").andReturn $.Deferred() 5 | spyOn(window, "setTimeout").andCallFake (cb) -> cb 6 | 7 | 8 | describe "constructor", -> 9 | it "should store the CouchDB URL", -> 10 | hoodie = new Hoodie 'http://couch.example.com' 11 | expect(hoodie.baseUrl).toBe 'http://couch.example.com' 12 | 13 | it "should remove trailing slash from passed URL", -> 14 | hoodie = new Hoodie 'http://couch.example.com/' 15 | expect(hoodie.baseUrl).toBe 'http://couch.example.com' 16 | 17 | it "should default the CouchDB URL to current domain with a api subdomain", -> 18 | # that's kind of hard to test. 19 | hoodie = new Hoodie 20 | expect(hoodie.baseUrl).toBe location.protocol + "//api." + location.hostname 21 | 22 | it "should check connection", -> 23 | spyOn(Hoodie::, "checkConnection") 24 | hoodie = new Hoodie 25 | expect(Hoodie::checkConnection).wasCalled() 26 | # /constructor 27 | 28 | 29 | describe "#request(type, path, options)", -> 30 | _when "request('GET', '/')", -> 31 | beforeEach -> 32 | @hoodie.request('GET', '/') 33 | @args = args = $.ajax.mostRecentCall.args[0] 34 | 35 | it "should send a GET request to http://couch.example.com/", -> 36 | expect(@args.type).toBe 'GET' 37 | expect(@args.url).toBe 'http://couch.example.com/' 38 | 39 | it "should set `dataType: 'json'", -> 40 | expect(@args.dataType).toBe 'json' 41 | 42 | it "should set `xhrFields` to `withCredentials: true`", -> 43 | expect(@args.xhrFields.withCredentials).toBe true 44 | 45 | it "should set `crossDomain: true`", -> 46 | expect(@args.crossDomain).toBe true 47 | 48 | it "should return a promise", -> 49 | promise = $.Deferred() 50 | $.ajax.andReturn promise 51 | expect(@hoodie.request('GET', '/')).toBe promise 52 | 53 | 54 | _when "request 'POST', '/test', data: funky: 'fresh'", -> 55 | beforeEach -> 56 | @hoodie.request 'POST', '/test', data: funky: 'fresh' 57 | @args = args = $.ajax.mostRecentCall.args[0] 58 | 59 | it "should send a POST request to http://couch.example.com/test", -> 60 | expect(@args.type).toBe 'POST' 61 | expect(@args.url).toBe 'http://couch.example.com/test' 62 | 63 | _when "request('GET', 'http://api.otherapp.com/')", -> 64 | beforeEach -> 65 | @hoodie.request('GET', 'http://api.otherapp.com/') 66 | @args = args = $.ajax.mostRecentCall.args[0] 67 | 68 | it "should send a GET request to http://api.otherapp.com/", -> 69 | expect(@args.type).toBe 'GET' 70 | expect(@args.url).toBe 'http://api.otherapp.com/' 71 | # /request(type, path, options) 72 | 73 | 74 | describe "#checkConnection()", -> 75 | beforeEach -> 76 | @requestDefer = @hoodie.defer() 77 | @hoodie._checkConnectionRequest = null 78 | spyOn(@hoodie, "request").andReturn @requestDefer.promise() 79 | spyOn(@hoodie, "trigger") 80 | window.setTimeout.andReturn null # prevent recursion 81 | 82 | it "should send GET / request", -> 83 | @hoodie.checkConnection() 84 | expect(@hoodie.request).wasCalledWith 'GET', '/' 85 | 86 | it "should only send one request at a time", -> 87 | @hoodie.checkConnection() 88 | @hoodie.checkConnection() 89 | expect(@hoodie.request.callCount).toBe 1 90 | 91 | _when "hoodie is online", -> 92 | beforeEach -> 93 | @hoodie.online = true 94 | 95 | _and "request succeeds", -> 96 | beforeEach -> 97 | @requestDefer.resolve {"couchdb":"Welcome","version":"1.2.1"} 98 | @hoodie.checkConnection() 99 | 100 | it "should check again in 30 seconds", -> 101 | expect(window.setTimeout).wasCalledWith @hoodie.checkConnection, 30000 102 | 103 | it "should not trigger `reconnected` event", -> 104 | expect(@hoodie.trigger).wasNotCalledWith 'reconnected' 105 | 106 | _and "request fails", -> 107 | beforeEach -> 108 | @requestDefer.reject {"status": 0,"statusText":"Error"} 109 | @hoodie.checkConnection() 110 | 111 | it "should check again in 3 seconds", -> 112 | expect(window.setTimeout).wasCalledWith @hoodie.checkConnection, 3000 113 | 114 | it "should trigger `disconnected` event", -> 115 | expect(@hoodie.trigger).wasCalledWith 'disconnected' 116 | 117 | _when "hoodie is offline", -> 118 | beforeEach -> 119 | @hoodie.online = false 120 | 121 | _and "request succeeds", -> 122 | beforeEach -> 123 | @requestDefer.resolve {"couchdb":"Welcome","version":"1.2.1"} 124 | @hoodie.checkConnection() 125 | 126 | it "should check again in 30 seconds", -> 127 | expect(window.setTimeout).wasCalledWith @hoodie.checkConnection, 30000 128 | 129 | it "should trigger `reconnected` event", -> 130 | expect(@hoodie.trigger).wasCalledWith 'reconnected' 131 | 132 | _and "request fails", -> 133 | beforeEach -> 134 | @requestDefer.reject {"status": 0,"statusText":"Error"} 135 | @hoodie.checkConnection() 136 | 137 | it "should check again in 3 seconds", -> 138 | expect(window.setTimeout).wasCalledWith @hoodie.checkConnection, 3000 139 | 140 | it "should not trigger `disconnected` event", -> 141 | expect(@hoodie.trigger).wasNotCalledWith 'disconnected' 142 | # /#checkConnection() 143 | 144 | describe "#open(store, options)", -> 145 | it "should instantiate a Remote instance", -> 146 | spyOn(Hoodie, "Remote") 147 | @hoodie.open "store_name", option: "value" 148 | expect(Hoodie.Remote).wasCalledWith @hoodie, name: "store_name", option: "value" 149 | # /open(store, options) 150 | 151 | 152 | describe "#uuid(num = 7)", -> 153 | it "should default to a length of 7", -> 154 | expect(@hoodie.uuid().length).toBe 7 155 | 156 | _when "called with num = 5", -> 157 | it "should generate an id with length = 5", -> 158 | expect(@hoodie.uuid(5).length).toBe 5 159 | # /#uuid(num) 160 | 161 | 162 | describe "#isPromise(object)", -> 163 | it "should return true if object is a promise", -> 164 | object = $.Deferred().promise() 165 | expect( @hoodie.isPromise(object) ).toBe true 166 | 167 | it "should return false for deferred objects", -> 168 | object = $.Deferred() 169 | expect( @hoodie.isPromise(object) ).toBe false 170 | 171 | it "should return false when object is undefined", -> 172 | expect( @hoodie.isPromise(undefined) ).toBe false 173 | # /#isPromise() 174 | 175 | describe "#resolve()", -> 176 | it "simply returns resolved promise", -> 177 | expect(@hoodie.resolve()).toBeResolved() 178 | 179 | it "should be applyable", -> 180 | promise = @hoodie.reject().then( null, @hoodie.resolve ) 181 | expect(promise).toBeResolved() 182 | # /#resolveWith(something) 183 | 184 | describe "#reject()", -> 185 | it "simply returns rejected promise", -> 186 | expect(@hoodie.reject()).toBeRejected() 187 | 188 | it "should be applyable", -> 189 | promise = @hoodie.resolve().then( @hoodie.reject ) 190 | expect(promise).toBeRejected() 191 | # /#resolveWith(something) 192 | 193 | describe "#resolveWith(something)", -> 194 | it "wraps passad arguments into a promise and returns it", -> 195 | promise = @hoodie.resolveWith('funky', 'fresh') 196 | expect(promise).toBeResolvedWith 'funky', 'fresh' 197 | 198 | it "should be applyable", -> 199 | promise = @hoodie.rejectWith(1, 2).then( null, @hoodie.resolveWith ) 200 | expect(promise).toBeResolvedWith 1, 2 201 | # /#resolveWith(something) 202 | 203 | describe "#rejectWith(something)", -> 204 | it "wraps passad arguments into a promise and returns it", -> 205 | promise = @hoodie.rejectWith('funky', 'fresh') 206 | expect(promise).toBeRejectedWith 'funky', 'fresh' 207 | 208 | it "should be applyable", -> 209 | promise = @hoodie.resolveWith(1, 2).then( @hoodie.rejectWith ) 210 | expect(promise).toBeRejectedWith 1, 2 211 | # /#rejectWith(something) 212 | 213 | describe "#dispose()", -> 214 | beforeEach -> 215 | spyOn(@hoodie, "trigger") 216 | 217 | it "should trigger `dispose` event", -> 218 | @hoodie.dispose() 219 | expect(@hoodie.trigger).wasCalledWith 'dispose' 220 | # /#dispose() 221 | # /Hoodie -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hoodie Test page 5 | 6 | 7 | 8 | 9 |
    10 |
    
     11 |     
     12 |     // 
     13 |     // initialize your Hoodie App
     14 |     //
     15 |     
     16 |     whereTheMagicHappens = 'https://yourapp.hood.ie';
     17 |     hoodie = new Hoodie(whereTheMagicHappens);
     18 |     
     19 |     // 
     20 |     // Account 
     21 |     //
     22 |     
     23 |     // sign up
     24 |     hoodie.account.signUp('joe@example.com', 'secret')
     25 |       
     26 |     // sign in
     27 |     hoodie.account.signIn('joe@example.com', 'secret')
     28 |       
     29 |     // sign out
     30 |     hoodie.account.signOut()
     31 |     
     32 |     // change password
     33 |     hoodie.account.changePassword('currentpassword', 'newpassword')
     34 | 
     35 |     // change username
     36 |     hoodie.account.changeUsername('currentpassword', 'newusername')
     37 |       
     38 |     // reset password
     39 |     hoodie.account.resetPassword('joe@example.com')
     40 | 
     41 |     // destroy account and all its data
     42 |     hoodie.account.destroy()
     43 |       
     44 |     //
     45 |     // Store
     46 |     //
     47 |     
     48 |     // add a new object
     49 |     type = 'couch'
     50 |     attributes = {color: "red"}
     51 |     hoodie.store.add(type, attributes )
     52 |       .done ( function(newObject) { } )
     53 |       
     54 |     // save an object
     55 |     type = 'couch'
     56 |     id   = 'abc4567'
     57 |     attributes  = {color: "red", name: "relax"}
     58 |     hoodie.store.save( type, id, attributes )
     59 |       .done ( function(object) { } )
     60 |       
     61 |     // update an existing object
     62 |     type = 'couch'
     63 |     id   = 'abc4567'
     64 |     update = {size: 2}
     65 |     hoodie.store.update( type, id, update )
     66 |       .done ( function(updatedObject) { } )
     67 |     
     68 |     // find one object
     69 |     type = 'couch'
     70 |     id   = 'abc4567'
     71 |     hoodie.store.find( type, id )
     72 |       .done ( function(object) { } )
     73 |       
     74 |     // Load all objects
     75 |     hoodie.store.findAll()
     76 |       .done ( function(objects) { } )
     77 |       
     78 |     // Load all objects from one type
     79 |     type = 'couch'
     80 |     hoodie.store.findAll( type )
     81 |       .done ( function(objects) { } )
     82 |       
     83 |     // remove an existing object
     84 |     type = 'couch'
     85 |     id   = 'abc4567'
     86 |     hoodie.store.remove( type, id )
     87 |       .done ( function(removedObject) { } )
     88 | 
     89 |     // listen to store events
     90 |     hoodie.store.on( 'add',    function( newObject) { } )
     91 | 
     92 |     // new doc created
     93 |     hoodie.store.on( 'add',    function( newObject) { } )
     94 | 
     95 |     // existing doc updated
     96 |     hoodie.store.on( 'update', function( updatedObject) { } )
     97 | 
     98 |     // doc removed
     99 |     hoodie.store.on( 'remove', function( removedObject) { } )
    100 | 
    101 |     // any of the events above
    102 |     hoodie.store.on( 'change', function( event, changedObject) { } )
    103 | 
    104 |     // all listeners can be filtered by type
    105 |     hoodie.store.on( "add:couch",    function( newObject) { } )
    106 |     hoodie.store.on( "update:couch", function( updatedObject)  { } )
    107 |     hoodie.store.on( "remove:couch", function( removedObject) { } )
    108 |     hoodie.store.on( "change:couch", function( event, changedObject) { } )
    109 | 
    110 |     // ... and by type and id
    111 |     hoodie.store.on( "add:couch:uuid123",    function( newObject) { } )
    112 |     hoodie.store.on( "update:couch:uuid123", function( updatedObject)  { } )
    113 |     hoodie.store.on( "remove:couch:uuid123", function( removedObject) { } )
    114 |     hoodie.store.on( "change:couch:uuid123", function( event, changedObject) { } )
    115 | 
    116 |       
    117 |     //
    118 |     // Synchronization
    119 |     //
    120 |     // When signed in, local changes do get synched automatically.
    121 |     // You can subscribe to remote updates
    122 |     // 
    123 |     
    124 |     // new doc created
    125 |     hoodie.remote.on( 'add', function( newObject) { } )
    126 | 
    127 |     // existing doc updated
    128 |     hoodie.remote.on( 'update', function( updatedObject) { } )
    129 | 
    130 |     // doc removed
    131 |     hoodie.remote.on( 'remove', function( removedObject) { } )
    132 | 
    133 |     // any of the events above
    134 |     hoodie.remote.on( 'change', function( event, changedObject) { } )
    135 | 
    136 |     // all listeners can be filtered by type
    137 |     hoodie.remote.on( "add:couch",  function( newObject) { } )
    138 |     hoodie.remote.on( "update:couch",  function( updatedObject)  { } )
    139 |     hoodie.remote.on( "remove:couch", function( removedObject) { } )
    140 |     hoodie.remote.on( "change:couch",  function( event, changedObject) { } )
    141 | 
    142 |     // ... and by type and id
    143 |     hoodie.remote.on( "add:couch:uuid123",  function( newObject) { } )
    144 |     hoodie.remote.on( "update:couch:uuid123",  function( updatedObject)  { } )
    145 |     hoodie.remote.on( "remove:couch:uuid123", function( removedObject) { } )
    146 |     hoodie.remote.on( "change:couch:uuid123",  function( event, changedObject) { } )
    147 | 
    148 | 
    149 |     //
    150 |     // Public Shares (Public User Stores)
    151 |     //
    152 |     // Select data you want to share with others and control exactly what will
    153 |     // be shared
    154 |     //
    155 | 
    156 |     // make couch object with id "abc4567" public
    157 |     hoodie.store.find("couch","abc4567").publish()
    158 | 
    159 |     // make couch with id "abc4567" public, but do only show the color, hide
    160 |     // all other attributes
    161 |     hoodie.store.find("couch","abc4567").publish(['color'])
    162 | 
    163 |     // make couch with id "abc4567" private again
    164 |     hoodie.store.find("couch","abc4567").unpublish()
    165 | 
    166 |     // find all couch objects from user "joe"
    167 |     hoodie.user("joe").findAll("couch").done( function(couches) { ... })
    168 | 
    169 | 
    170 |     //
    171 |     // Global Public Store
    172 |     // 
    173 |     // When enabled, all publicly shared objects by all users will be 
    174 |     // available through the hoodie.global API
    175 |     //
    176 | 
    177 |     // find all public songs from all users
    178 |     hoodie.global.findAll("song").done( function(songs) { ... })
    179 | 
    180 |     
    181 |     //
    182 |     // Sharing
    183 |     //
    184 |     // The hoodie.share module allows to share objects with other users. A share
    185 |     // can be public, which means everybody knowing its id can access it. Or the 
    186 |     // access can be limited to specific users. Optionally, a password can be set
    187 |     // for additional security. Access can be differenciated between read and write.
    188 |     //
    189 | 
    190 |     // add a new share
    191 |     hoodie.share.add().done( function(share) {} )
    192 | 
    193 |     // grant / revoke access
    194 |     share.grantReadAccess()
    195 |     share.grantWriteAccess()
    196 |     share.revokeReadAccess()
    197 |     share.revokeWriteAccess()
    198 |     share.grantReadAccess('joe@example.com')
    199 |     share.revomeWriteAccess(['joe@example.com','lisa@example.com'])
    200 | 
    201 |     // add all todo objects to the share
    202 |     hoodie.store.findAll('todo').shareAt(share.id)
    203 | 
    204 |     // remove a specific todo from the share
    205 |     hoodie.store.find('todo', '123').unshareAt(share.id)
    206 | 
    207 |     // add a new share and add some of my objects to it in one step
    208 |     hoodie.store.findAll('todo').share()
    209 |     .done( function(todos, share) { alert('shared at ' + share.id) } )
    210 | 
    211 |     // remove objects from all shares
    212 |     hoodie.store.findAll('todo').unshare()
    213 | 
    214 |     // remove share
    215 |     hoodie.share.remove(share.id)
    216 | 
    217 |     // open a share and load all its objects
    218 |     hoodie.share('shareIdHere').findAll()
    219 |       .done( function(objects) { } )
    220 | 
    221 |     // subscribe / unsubscribe
    222 |     hoodie.share('shareId').subscribe()
    223 |     hoodie.share('shareId').unsubscribe()
    224 |       
    225 |     
    226 |     //
    227 |     // Send emails
    228 |     // 
    229 |     email = {
    230 |       to      : ['you@roundthewor.ld'],
    231 |       cc      : ['rest@roundthewor.ld'],
    232 |       subject : 'rule the wolrd',
    233 |       body    : "we can do it!\nSigned, Joe"
    234 |     }
    235 | 
    236 |     hoodie.email.send( email )
    237 | 
    238 |       // synched to server
    239 |       .progress ( function(email) { } )
    240 | 
    241 |       // email sent successfully
    242 |       .done     ( function(email) { } )
    243 | 
    244 |       // something went wrong
    245 |       .fail     ( function(err)   { } )
    246 | 
    247 |     //
    248 |     // hoodie.js API docs
    249 |     // http://hoodiehq.github.com/hoodie.js/doc/hoodie.html
    250 |     //
    251 |     
    252 |
    253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /doc/events.html: -------------------------------------------------------------------------------- 1 | events
    src/events.coffee
    #

    Events

    #

    extend any Class with support for

    #
      2 |
    • object.bind('event', cb)
    • 3 |
    • object.unbind('event', cb)
    • 4 |
    • object.trigger('event', args...)
    • 5 |
    • object.one('ev', cb)
    • 6 |
    #
    # 7 | 8 | class Events

    Bind

    #

    bind a callback to an event triggerd by the object

    #
    object.bind 'cheat', blame
     9 | 
    # 10 | bind: (ev, callback) -> 11 | evs = ev.split(' ') 12 | calls = @hasOwnProperty('_callbacks') and @_callbacks or= {} 13 | 14 | for name in evs 15 | calls[name] or= [] 16 | calls[name].push(callback) 17 |

    alias

    on: @::bind

    one

    18 | 19 |

    same as bind, but does get executed only once

    20 | 21 |
    object.one 'groundTouch', gameOver
    22 | 
    one: (ev, callback) -> 23 | @bind ev, -> 24 | @unbind(ev, arguments.callee) 25 | callback.apply(@, arguments)

    trigger

    #

    trigger an event and pass optional parameters for binding.

    #
    object.trigger 'win', score: 1230
    26 | 
    trigger: (args...) -> 27 | ev = args.shift() 28 | 29 | list = @hasOwnProperty('_callbacks') and @_callbacks?[ev] 30 | return unless list 31 | 32 | callback.apply(@, args) for callback in list 33 | 34 | return true

    unbind

    #

    unbind to from all bindings, from all bindings of a specific event 35 | or from a specific binding.

    #
    object.unbind()
    36 | object.unbind 'move'
    37 | object.unbind 'move', follow
    38 | 
    # 39 | unbind: (ev, callback) -> 40 | unless ev 41 | @_callbacks = {} 42 | return this 43 | 44 | list = @_callbacks?[ev] 45 | return this unless list 46 | 47 | unless callback 48 | delete @_callbacks[ev] 49 | return this 50 | 51 | for cb, i in list when cb is callback 52 | list = list.slice() 53 | list.splice(i, 1) 54 | @_callbacks[ev] = list 55 | break 56 | 57 | return this
    -------------------------------------------------------------------------------- /wishlist/doc/open.html: -------------------------------------------------------------------------------- 1 | open
    open.js

    hoodie.open

    2 | 3 |

    just some loose thoughts on a hoodie.open method.

    open a "store"

    hoodie.open("user/joe") 4 | hoodie.open("user/jane/public").store.findAll( function(objects) {}) 5 | hoodie.open("share/abc8320", {password: "secret"}).subscribe() 6 | hoodie.open("global").on("store:created:track", function(track) {})

    shortcuts

    hoodie.remote.push() 7 | hoodie.user('jane').store.findAll( function(objects) {}) 8 | hoodie.share('abc832', {password: "secret"}).subscribe() 9 | hoodie.global.on("store:created:track", function(track) {})

    a "store" module?

    10 | 11 |

    I can open any kind of named store, like a sharing or a users public 12 | store. An "opened" store does always provide the same API whereat 13 | some might require special privileges. They all return a promise

    instantiate

    share = hoodie("share/abc8320")

    store / find objects

    share.store.find("todolist","xy20ad9") 14 | share.store.findAll("todo") 15 | share.store.create("todo", {name: "remember the milk"}) 16 | share.store.save("todo", "exists7", {name: "get some rest"}) 17 | share.store.update("todo", "exists7", {name: "get some rest"}) 18 | share.store.updateAll("todo", {done: true}) 19 | share.store.remove("todo", "exists7") 20 | share.store.removeAll("todo") 21 | share.store.get("completed_todos") 22 | share.store.post("notify", {"email": "jane@xmpl.com"})

    sync

    share.connect() 23 | share.disconnect() 24 | share.pull() 25 | share.push() 26 | share.sync()

    event binding

    share.on("event", callback)

    options

    password

    hoodie.open("share/abc8320", { 27 | password: "secret" 28 | })
    -------------------------------------------------------------------------------- /doc/core/account_remote.html: -------------------------------------------------------------------------------- 1 | core/account_remote
    src/core/account_remote.coffee

    AccountRemote

    Connection / Socket to our couch

    #

    AccountRemote is using CouchDB's _changes feed to 2 | listen to changes and _bulk_docs to push local changes

    #

    When hoodie.remote is continuously syncing (default), 3 | it will continuously synchronize with local store, 4 | otherwise sync, pull or push can be called manually

    # 5 | class Hoodie.AccountRemote extends Hoodie.Remote

    properties

    connect by default

    connected: true

    Constructor

    # 6 | constructor : (@hoodie, options = {}) ->

    set name to user's DB name

    @name = @hoodie.account.db() 7 |

    we're always connected to our own db

    @connected = true

    do not prefix files for my own remote

    options.prefix = '' 8 | 9 | @hoodie.on 'account:authenticated', @_handleAuthenticate 10 | @hoodie.on 'account:signout', @disconnect 11 | @hoodie.on 'reconnected', @connect 12 | 13 | super(@hoodie, options) 14 |

    Connect

    do not start to connect immediately, but authenticate beforehand

    connect : => 15 | @hoodie.account.authenticate().pipe => 16 | @hoodie.on 'store:idle', @push 17 | @push() 18 | super

    disconnect

    disconnect: => 19 | @hoodie.unbind 'store:idle', @push 20 | super 21 |

    get and set since nr

    we store the last since number from the current user's store 22 | in his config

    getSinceNr : (since) -> 23 | @hoodie.config.get('_remote.since') or 0 24 | setSinceNr : (since) -> 25 | @hoodie.config.set('_remote.since', since)

    push

    if no objects passed to be pushed, we default to 26 | changed objects in user's local store

    push : (objects) => 27 | objects = @hoodie.store.changedObjects() unless $.isArray objects 28 | promise = super(objects) 29 | promise.fail @hoodie.checkConnection 30 | return promise

    Events

    namespaced alias for hoodie.on

    on : (event, cb) -> 31 | event = event.replace /(^| )([^ ]+)/g, "$1remote:$2" 32 | @hoodie.on event, cb 33 | one : (event, cb) -> 34 | event = event.replace /(^| )([^ ]+)/g, "$1remote:$2" 35 | @hoodie.one event, cb 36 |

    namespaced alias for hoodie.trigger

    trigger : (event, parameters...) -> 37 | @hoodie.trigger "remote:#{event}", parameters...

    Private

    _handleAuthenticate : => 38 | @name = @hoodie.account.db() 39 | @connect()
    -------------------------------------------------------------------------------- /test/specs/core/store.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "Hoodie.Store", -> 2 | beforeEach -> 3 | @hoodie = new Mocks.Hoodie 4 | @store = new Hoodie.Store @hoodie 5 | 6 | describe "#save(type, id, object, options)", -> 7 | beforeEach -> 8 | spyOn(@store, "_now").andReturn 'now' 9 | 10 | it "should return a defer", -> 11 | promise = @store.save 'document', '123', name: 'test' 12 | expect(promise).toBeDefer() 13 | 14 | describe "invalid arguments", -> 15 | _when "no arguments passed", -> 16 | it "should be rejected", -> 17 | expect(@store.save()).toBeRejected() 18 | 19 | _when "no object passed", -> 20 | it "should be rejected", -> 21 | promise = @store.save 'document', 'abc4567' 22 | expect(promise).toBeRejected() 23 | 24 | it "should not allow type containing /", -> 25 | invalid = ['a/b'] 26 | valid = ['car', '$email'] 27 | 28 | for key in invalid 29 | promise = @store.save key, 'valid', {} 30 | expect(promise).toBeRejected() 31 | 32 | for key in valid 33 | promise = @store.save key, 'valid', {} 34 | expect(promise).toBeDefer() 35 | 36 | it "should not allow id containing /", -> 37 | invalid = ['/'] 38 | valid = ['abc4567', '1', 123, 'abc-567'] 39 | 40 | for key in invalid 41 | promise = @store.save 'valid', key, {} 42 | expect(promise).toBeRejected() 43 | 44 | for key in valid 45 | promise = @store.save 'valid', key, {} 46 | expect(promise).toBeDefer() 47 | 48 | 49 | describe "add(type, object)", -> 50 | beforeEach -> 51 | spyOn(@store, "save").andReturn "save_promise" 52 | 53 | it "should proxy to save method", -> 54 | @store.add("test", {funky: "value"}) 55 | expect(@store.save).wasCalledWith "test", undefined, funky: "value" 56 | 57 | it "should return promise of save method", -> 58 | expect(@store.add()).toBe 'save_promise' 59 | # /add(type, object) 60 | 61 | describe "#update(type, id, update, options)", -> 62 | beforeEach -> 63 | spyOn(@store, "find") 64 | spyOn(@store, "save").andReturn then: -> 65 | 66 | _when "object cannot be found", -> 67 | beforeEach -> 68 | @store.find.andReturn $.Deferred().reject() 69 | @promise = @store.update 'couch', '123', funky: 'fresh' 70 | 71 | it "should add it", -> 72 | expect(@store.save).wasCalledWith 'couch', '123', funky: 'fresh', undefined 73 | 74 | _when "object can be found", -> 75 | beforeEach -> 76 | @store.find.andReturn @hoodie.defer().resolve { style: 'baws' } 77 | @store.save.andReturn @hoodie.defer().resolve 'resolved by save' 78 | 79 | _and "update is an object", -> 80 | beforeEach -> 81 | @promise = @store.update 'couch', '123', { funky: 'fresh' } 82 | 83 | it "should save the updated object", -> 84 | expect(@store.save).wasCalledWith 'couch', '123', { style: 'baws', funky: 'fresh' }, undefined 85 | 86 | it "should return a resolved promise", -> 87 | expect(@promise).toBeResolvedWith 'resolved by save' 88 | 89 | _and "update is an object and options passed", -> 90 | beforeEach -> 91 | @promise = @store.update 'couch', '123', { funky: 'fresh' }, silent: true 92 | 93 | it "should not save the object", -> 94 | expect(@store.save).wasCalledWith 'couch', '123', {style: 'baws', funky: 'fresh'}, {silent: true} 95 | 96 | _and "update is a function", -> 97 | beforeEach -> 98 | @promise = @store.update 'couch', '123', (obj) -> funky: 'fresh' 99 | 100 | it "should save the updated object", -> 101 | expect(@store.save).wasCalledWith 'couch', '123', { style: 'baws', funky: 'fresh' }, undefined 102 | 103 | it "should return a resolved promise", -> 104 | expect(@promise).toBeResolvedWith 'resolved by save' 105 | 106 | it "should make a deep copy and save", -> 107 | @store.save.reset() 108 | originalObject = { config: {} } 109 | @store.find.andReturn @hoodie.defer().resolve originalObject 110 | @store.update 'couch', '123', (obj) -> 111 | obj.config.funky = 'fresh' 112 | return obj 113 | expect(originalObject.config.funky).toBeUndefined() 114 | expect(@store.save).wasCalled() 115 | 116 | _and "update wouldn't make a change", -> 117 | beforeEach -> 118 | @promise = @store.update 'couch', '123', (obj) -> style: 'baws' 119 | 120 | it "should not save the object", -> 121 | expect(@store.save).wasNotCalled() 122 | 123 | it "should return a resolved promise", -> 124 | expect(@promise).toBeResolvedWith {style: 'baws'} 125 | 126 | _but "update wouldn't make a change, but options have been passed", -> 127 | beforeEach -> 128 | @promise = @store.update 'couch', '123', {}, public: true 129 | 130 | it "should not save the object", -> 131 | expect(@store.save).wasCalledWith 'couch', '123', style: 'baws', {public: true} 132 | 133 | 134 | 135 | # /#update(type, id, update, options) 136 | 137 | describe "#updateAll(objects)", -> 138 | beforeEach -> 139 | spyOn(@hoodie, "isPromise").andReturn false 140 | @todoObjects = [ 141 | {type: 'todo', id: '1'} 142 | {type: 'todo', id: '2'} 143 | {type: 'todo', id: '3'} 144 | ] 145 | 146 | it "should return a promise", -> 147 | expect(@store.updateAll(@todoObjects, {})).toBePromise() 148 | 149 | it "should update objects", -> 150 | spyOn(@store, "update") 151 | @store.updateAll @todoObjects, {funky: 'update'} 152 | for obj in @todoObjects 153 | expect(@store.update).wasCalledWith obj.type, obj.id, {funky: 'update'}, {} 154 | 155 | it "should resolve the returned promise once all objects have been updated", -> 156 | promise = @hoodie.defer().resolve().promise() 157 | spyOn(@store, "update").andReturn promise 158 | expect(@store.updateAll(@todoObjects, {})).toBeResolved() 159 | 160 | it "should not resolve the retunred promise unless object updates have been finished", -> 161 | promise = @hoodie.defer().promise() 162 | spyOn(@store, "update").andReturn promise 163 | expect(@store.updateAll(@todoObjects, {})).notToBeResolved() 164 | 165 | 166 | _when "passed objects is a promise", -> 167 | beforeEach -> 168 | @hoodie.isPromise.andReturn true 169 | 170 | it "should update objects returned by promise", -> 171 | promise = @hoodie.defer().resolve(@todoObjects).promise() 172 | spyOn(@store, "update") 173 | @store.updateAll promise, {funky: 'update'} 174 | for obj in @todoObjects 175 | expect(@store.update).wasCalledWith obj.type, obj.id, {funky: 'update'}, {} 176 | 177 | it "should update object single object returned by promise", -> 178 | obj = @todoObjects[0] 179 | promise = @hoodie.defer().resolve(obj).promise() 180 | spyOn(@store, "update") 181 | @store.updateAll promise, {funky: 'update'} 182 | expect(@store.update).wasCalledWith obj.type, obj.id, {funky: 'update'}, {} 183 | 184 | _when "passed objects is a type (string)", -> 185 | beforeEach -> 186 | findAll_promise = jasmine.createSpy "findAll_promise" 187 | spyOn(@store, "findAll").andReturn pipe: findAll_promise 188 | 189 | it "should update objects return by findAll(type)", -> 190 | @store.updateAll "car", {funky: 'update'} 191 | expect(@store.findAll).wasCalledWith "car" 192 | 193 | _when "no objects passed", -> 194 | beforeEach -> 195 | findAll_promise = jasmine.createSpy "findAll_promise" 196 | spyOn(@store, "findAll").andReturn pipe: findAll_promise 197 | 198 | it "should update all objects", -> 199 | @store.updateAll null, {funky: 'update'} 200 | expect(@store.findAll).wasCalled() 201 | expect(@store.findAll.mostRecentCall.args.length).toBe 0 202 | # /#updateAll(objects) 203 | 204 | describe "#find(type, id)", -> 205 | it "should return a defer", -> 206 | defer = @store.find 'document', '123' 207 | expect(defer).toBeDefer() 208 | 209 | describe "invalid arguments", -> 210 | _when "no arguments passed", -> 211 | it "should be rejected", -> 212 | promise = @store.find() 213 | expect(promise).toBeRejected() 214 | 215 | _when "no id passed", -> 216 | it "should be rejected", -> 217 | promise = @store.find 'document' 218 | expect(promise).toBeRejected() 219 | 220 | describe "aliases", -> 221 | beforeEach -> 222 | spyOn(@store, "find") 223 | 224 | it "should allow to use .find", -> 225 | @store.find 'test', '123' 226 | expect(@store.find).wasCalledWith 'test', '123' 227 | # /#find(type, id) 228 | 229 | describe "#findAll(type)", -> 230 | it "should return a defer", -> 231 | expect(@store.findAll()).toBeDefer() 232 | 233 | describe "aliases", -> 234 | beforeEach -> 235 | spyOn(@store, "findAll") 236 | # /#findAll(type) 237 | 238 | describe "#findOrAdd(type, id, attributes)", -> 239 | _when "object exists", -> 240 | beforeEach -> 241 | promise = @hoodie.defer().resolve('existing_object').promise() 242 | spyOn(@store, "find").andReturn promise 243 | 244 | it "should resolve with existing object", -> 245 | promise = @store.findOrAdd 'type', '123', attribute: 'value' 246 | expect(promise).toBeResolvedWith 'existing_object' 247 | 248 | _when "object does not exist", -> 249 | beforeEach -> 250 | spyOn(@store, "find").andReturn @hoodie.defer().reject().promise() 251 | 252 | it "should call `.add` with passed attributes", -> 253 | spyOn(@store, "add").andReturn @hoodie.defer().promise() 254 | promise = @store.findOrAdd 'type', 'id123', attribute: 'value' 255 | expect(@store.add).wasCalledWith 'type', id: 'id123', attribute: 'value' 256 | 257 | it "should reject when `.add` was rejected", -> 258 | spyOn(@store, "add").andReturn @hoodie.defer().reject().promise() 259 | promise = @store.findOrAdd id: '123', attribute: 'value' 260 | expect(promise).toBeRejected() 261 | 262 | it "should resolve when `.add` was resolved", -> 263 | promise = @hoodie.defer().resolve('new_object').promise() 264 | spyOn(@store, "add").andReturn promise 265 | promise = @store.findOrAdd id: '123', attribute: 'value' 266 | expect(promise).toBeResolvedWith 'new_object' 267 | # /#findOrAdd(attributes) 268 | 269 | describe "#remove(type, id)", -> 270 | it "should return a defer", -> 271 | defer = @store.remove 'document', '123' 272 | expect(defer).toBeDefer() 273 | 274 | describe "invalid arguments", -> 275 | _when "no arguments passed", -> 276 | it "should be rejected", -> 277 | promise = @store.remove() 278 | expect(promise).toBeRejected() 279 | 280 | _when "no id passed", -> 281 | it "should be rejected", -> 282 | promise = @store.remove 'document' 283 | expect(promise).toBeRejected() 284 | # /aliases 285 | # /#remove(type, id) 286 | 287 | describe "#removeAll(type)", -> 288 | beforeEach -> 289 | @findAllDefer = @hoodie.defer() 290 | spyOn(@store, "findAll").andReturn @findAllDefer.promise() 291 | 292 | it "should return a promise", -> 293 | expect(@store.removeAll()).toBePromise() 294 | 295 | it "should call store.findAll", -> 296 | @store.removeAll('filter') 297 | expect(@store.findAll).wasCalledWith 'filter' 298 | 299 | _when "store.findAll fails", -> 300 | beforeEach -> 301 | @findAllDefer.reject error: 'because' 302 | 303 | it "should return a rejected promise", -> 304 | promise = @store.removeAll() 305 | expect(promise).toBeRejectedWith error: 'because' 306 | 307 | _when "store.findAll returns 3 objects", -> 308 | beforeEach -> 309 | spyOn(@store, "remove") 310 | @object1 = { type: 'task', id: '1', title: 'some'} 311 | @object2 = { type: 'task', id: '2', title: 'thing'} 312 | @object3 = { type: 'task', id: '3', title: 'funny'} 313 | @findAllDefer.resolve [@object1, @object2, @object3] 314 | 315 | it "should call remove for each object", -> 316 | @store.removeAll() 317 | expect(@store.remove).wasCalledWith 'task', '1', {} 318 | expect(@store.remove).wasCalledWith 'task', '2', {} 319 | expect(@store.remove).wasCalledWith 'task', '3', {} 320 | 321 | it "should pass options", -> 322 | @store.removeAll(null, something: 'optional') 323 | expect(@store.remove).wasCalledWith 'task', '1', something: 'optional' 324 | expect(@store.remove).wasCalledWith 'task', '2', something: 'optional' 325 | expect(@store.remove).wasCalledWith 'task', '3', something: 'optional' 326 | # /#removeAll(type) 327 | # /Hoodie.Store 328 | ### -------------------------------------------------------------------------------- /test/specs/extensions/share.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "Hoodie.Share", -> 2 | beforeEach -> 3 | @hoodie = new Mocks.Hoodie 4 | @share = new Hoodie.Share @hoodie 5 | spyOn(@share, "instance") 6 | 7 | describe "constructor", -> 8 | 9 | it "should extend hoodie.store API with share / unshare methods", -> 10 | spyOn(@hoodie.store, "decoratePromises") 11 | new Hoodie.Share @hoodie 12 | expect(@hoodie.store.decoratePromises).wasCalled() 13 | {share, shareAt, unshareAt, unshare} = @hoodie.store.decoratePromises.mostRecentCall.args[0] 14 | expect(typeof share).toBe 'function' 15 | expect(typeof shareAt).toBe 'function' 16 | expect(typeof unshareAt).toBe 'function' 17 | expect(typeof unshare).toBe 'function' 18 | 19 | describe "direct call", -> 20 | beforeEach -> 21 | spyOn(@hoodie, "open") 22 | 23 | it "should init a new share instance", -> 24 | spyOn(Hoodie, "ShareInstance") 25 | share = new Hoodie.Share @hoodie 26 | instance = share('funk123', option: 'value') 27 | expect(share.instance).wasCalled() 28 | # /direct call 29 | 30 | describe "#instance", -> 31 | it "should point to Hoodie.ShareInstance", -> 32 | share = new Hoodie.Share @hoodie 33 | expect(share.instance).toBe Hoodie.ShareInstance 34 | # /#instance 35 | 36 | describe "#add(attributes)", -> 37 | beforeEach -> 38 | @instance = jasmine.createSpy("instance") 39 | @share.instance.andReturn @instance 40 | @addDefer = @hoodie.defer() 41 | spyOn(@hoodie.store, "add").andReturn @addDefer.promise() 42 | 43 | it "should add new object in hoodie.store", -> 44 | @share.add(id: '123') 45 | expect(@hoodie.store.add).wasCalledWith '$share', id: '123' 46 | 47 | _when "store.add successful", -> 48 | it "should resolve with a share instance", -> 49 | @addDefer.resolve hell: 'yeah' 50 | promise = @share.add(funky: 'fresh') 51 | expect(promise).toBeResolvedWith @instance 52 | 53 | _and "user has no account yet", -> 54 | beforeEach -> 55 | spyOn(@hoodie.account, "hasAccount").andReturn false 56 | spyOn(@hoodie.account, "anonymousSignUp") 57 | 58 | it "should sign up anonymously", -> 59 | @share.add(id: '123') 60 | @addDefer.resolve hell: 'yeah' 61 | expect(@hoodie.account.anonymousSignUp).wasCalled() 62 | # /#add(attributes) 63 | 64 | describe "#find(share_id)", -> 65 | beforeEach -> 66 | promise = @hoodie.defer().resolve(funky: 'fresh').promise() 67 | spyOn(@hoodie.store, "find").andReturn promise 68 | @share.instance.andCallFake -> this.foo = 'bar' 69 | 70 | it "should proxy to store.find('$share', share_id)", -> 71 | promise = @share.find '123' 72 | expect(@hoodie.store.find).wasCalledWith '$share', '123' 73 | 74 | it "should resolve with a Share Instance", -> 75 | @hoodie.store.find.andReturn @hoodie.defer().resolve({}).promise() 76 | @share.instance.andCallFake -> this.foo = 'bar' 77 | promise = @share.find '123' 78 | expect(promise).toBeResolvedWith foo: 'bar' 79 | # /#find(share_id) 80 | 81 | describe "#findOrAdd(id, share_attributes)", -> 82 | beforeEach -> 83 | @findOrAddDefer = @hoodie.defer() 84 | spyOn(@hoodie.store, "findOrAdd").andReturn @findOrAddDefer.promise() 85 | 86 | it "should proxy to hoodie.store.findOrAdd with type set to '$share'", -> 87 | @share.findOrAdd 'id123', {} 88 | expect(@hoodie.store.findOrAdd).wasCalledWith '$share', 'id123', {} 89 | 90 | it "should not filter out createdBy property", -> 91 | @share.findOrAdd 'id123', createdBy : 'me' 92 | expect(@hoodie.store.findOrAdd).wasCalledWith '$share', 'id123', createdBy : 'me' 93 | 94 | _when "store.findOrAdd successful", -> 95 | it "should resolve with a Share Instance", -> 96 | @findOrAddDefer.resolve {} 97 | @share.instance.andCallFake -> this.foo = 'bar' 98 | promise = @share.findOrAdd 'id123', {} 99 | expect(promise).toBeResolvedWith foo: 'bar' 100 | 101 | _and "user has no account yet", -> 102 | beforeEach -> 103 | spyOn(@hoodie.account, "hasAccount").andReturn false 104 | spyOn(@hoodie.account, "anonymousSignUp") 105 | 106 | it "should sign up anonymously", -> 107 | @share.findOrAdd(id: '123', {}) 108 | @findOrAddDefer.resolve {} 109 | expect(@hoodie.account.anonymousSignUp).wasCalled() 110 | # /#findOrAdd(share_attributes) 111 | 112 | describe "#findAll()", -> 113 | beforeEach -> 114 | spyOn(@hoodie.store, "findAll").andCallThrough() 115 | 116 | it "should proxy to hoodie.store.findAll('$share')", -> 117 | @hoodie.store.findAll.andCallThrough() 118 | @share.findAll() 119 | expect(@hoodie.store.findAll).wasCalledWith '$share' 120 | 121 | it "should resolve with an array of Share instances", -> 122 | @hoodie.store.findAll.andReturn @hoodie.defer().resolve([{}, {}]).promise() 123 | @share.instance.andCallFake -> this.foo = 'bar' 124 | promise = @share.findAll() 125 | expect(promise).toBeResolvedWith [{foo: 'bar'}, {foo: 'bar'}] 126 | # /#findAll() 127 | 128 | describe "#save('share_id', attributes)", -> 129 | beforeEach -> 130 | spyOn(@hoodie.store, "save").andCallThrough() 131 | 132 | it "should proxy to hoodie.store.save('$share', 'share_id', attributes)", -> 133 | @share.save('abc4567', access: true) 134 | expect(@hoodie.store.save).wasCalledWith '$share', 'abc4567', access: true 135 | 136 | it "should resolve with a Share Instance", -> 137 | @hoodie.store.save.andReturn @hoodie.defer().resolve({}).promise() 138 | @share.instance.andCallFake -> this.foo = 'bar' 139 | promise = @share.save {} 140 | expect(promise).toBeResolvedWith foo: 'bar' 141 | # /#save('share_id', attributes) 142 | 143 | describe "#update('share_id', changed_attributes)", -> 144 | beforeEach -> 145 | spyOn(@hoodie.store, "update").andCallThrough() 146 | 147 | it "should proxy to hoodie.store.update('$share', 'share_id', attributes)", -> 148 | @share.update('abc4567', access: true) 149 | expect(@hoodie.store.update).wasCalledWith '$share', 'abc4567', access: true 150 | 151 | it "should resolve with a Share Instance", -> 152 | @hoodie.store.update.andReturn @hoodie.defer().resolve({}).promise() 153 | @share.instance.andCallFake -> this.foo = 'bar' 154 | promise = @share.update {} 155 | expect(promise).toBeResolvedWith foo: 'bar' 156 | # /#update('share_id', changed_attributes) 157 | 158 | 159 | describe "#updateAll(changed_attributes)", -> 160 | beforeEach -> 161 | spyOn(@hoodie.store, "updateAll").andCallThrough() 162 | 163 | it "should proxy to hoodie.store.updateAll('$share', changed_attributes)", -> 164 | @hoodie.store.updateAll.andCallThrough() 165 | @share.updateAll( access: true ) 166 | expect(@hoodie.store.updateAll).wasCalledWith '$share', access: true 167 | 168 | it "should resolve with an array of Share instances", -> 169 | @hoodie.store.updateAll.andReturn @hoodie.defer().resolve([{}, {}]).promise() 170 | @share.instance.andCallFake -> this.foo = 'bar' 171 | promise = @share.updateAll access: true 172 | expect(promise).toBeResolvedWith [{foo: 'bar'}, {foo: 'bar'}] 173 | # /#findAll() 174 | 175 | 176 | describe "#remove(share_id)", -> 177 | beforeEach -> 178 | spyOn(@hoodie.store, "findAll").andReturn unshareAt: -> 179 | spyOn(@hoodie.store, "remove").andReturn 'remove_promise' 180 | 181 | it "should init the share instance and remove it", -> 182 | promise = @share.remove '123' 183 | expect(promise).toBe 'remove_promise' 184 | # /#remove(share_id) 185 | 186 | 187 | describe "#removeAll()", -> 188 | beforeEach -> 189 | spyOn(@hoodie.store, "findAll").andReturn unshare: -> 190 | spyOn(@hoodie.store, "removeAll").andReturn 'remove_promise' 191 | 192 | it "should init the share instance and remove it", -> 193 | promise = @share.removeAll() 194 | expect(promise).toBe 'remove_promise' 195 | # /#removeAll() 196 | 197 | describe "hoodie.store promise decorations", -> 198 | beforeEach -> 199 | @storeDefer = @hoodie.defer() 200 | spyOn(@hoodie.store, "update") 201 | 202 | describe "#shareAt(shareId, properties)", -> 203 | _when "promise returns one object", -> 204 | beforeEach -> 205 | @promise = @storeDefer.resolve 206 | type: 'task' 207 | id: '123' 208 | title: 'milk' 209 | @promise.hoodie = @hoodie 210 | 211 | it "should save object returned by promise with {$sharedAt: 'shareId'}", -> 212 | Hoodie.Share::_storeShareAt.apply(@promise, ['shareId']) 213 | expect(@hoodie.store.update).wasCalledWith 'task', '123', {$sharedAt: 'shareId'} 214 | 215 | _when "promise returns multiple objects", -> 216 | beforeEach -> 217 | @promise = @storeDefer.resolve [ 218 | {type: 'task', id: '123', title: 'milk'} 219 | {type: 'task', id: '456', title: 'milk'} 220 | ] 221 | @promise.hoodie = @hoodie 222 | 223 | it "should update object returned by promise with $public: true", -> 224 | Hoodie.Share::_storeShareAt.apply(@promise, ['shareId']) 225 | expect(@hoodie.store.update).wasCalledWith 'task', '123', {$sharedAt: 'shareId'} 226 | expect(@hoodie.store.update).wasCalledWith 'task', '456', {$sharedAt: 'shareId'} 227 | # /shareAt() 228 | 229 | describe "#unshareAt(shareId)", -> 230 | _when "object is currently shared at 'shareId'", -> 231 | beforeEach -> 232 | @promise = @storeDefer.resolve 233 | type: 'task' 234 | id: '123' 235 | title: 'milk' 236 | $sharedAt: 'shareId' 237 | @promise.hoodie = @hoodie 238 | 239 | it "should save object returned by promise with {$unshared: true}", -> 240 | Hoodie.Share::_storeUnshareAt.apply(@promise, ['shareId']) 241 | expect(@hoodie.store.update).wasCalledWith 'task', '123', {$unshared: true} 242 | 243 | _when "promise returns multiple objects, of which some are shared at 'shareId'", -> 244 | beforeEach -> 245 | @promise = @storeDefer.resolve [ 246 | {type: 'task', id: '123', title: 'milk'} 247 | {type: 'task', id: '456', title: 'milk', $sharedAt: 'shareId'} 248 | ] 249 | @promise.hoodie = @hoodie 250 | 251 | it "should update objects returned by promise with {$unshared: true}", -> 252 | Hoodie.Share::_storeUnshareAt.apply(@promise, ['shareId']) 253 | expect(@hoodie.store.update).wasNotCalledWith 'task', '123', {$unshared: true} 254 | expect(@hoodie.store.update).wasCalledWith 'task', '456', {$unshared: true} 255 | # /#unshareAt() 256 | 257 | describe "#unshare()", -> 258 | _when "promise returns one object", -> 259 | beforeEach -> 260 | @promise = @storeDefer.resolve 261 | type: 'task' 262 | id: '123' 263 | title: 'milk' 264 | $sharedAt: 'shareId' 265 | @promise.hoodie = @hoodie 266 | 267 | it "should save object returned by promise with {$unshared: true}", -> 268 | Hoodie.Share::_storeUnshare.apply(@promise, []) 269 | expect(@hoodie.store.update).wasCalledWith 'task', '123', {$unshared: true} 270 | 271 | _when "promise returns multiple objects, of which some are shared at 'shareId'", -> 272 | beforeEach -> 273 | @promise = @storeDefer.resolve [ 274 | {type: 'task', id: '123', title: 'milk'} 275 | {type: 'task', id: '456', title: 'milk', $sharedAt: 'shareId'} 276 | ] 277 | @promise.hoodie = @hoodie 278 | 279 | it "should update objects returned by promise with {$unshared: true}", -> 280 | Hoodie.Share::_storeUnshare.apply(@promise, []) 281 | expect(@hoodie.store.update).wasNotCalledWith 'task', '123', {$unshared: true} 282 | expect(@hoodie.store.update).wasCalledWith 'task', '456', {$unshared: true} 283 | # /#unshare() 284 | 285 | 286 | describe "#share(shareId, properties)", -> 287 | _when "promise returns one object", -> 288 | beforeEach -> 289 | @promise = @storeDefer.resolve 290 | type: 'task' 291 | id: '123' 292 | title: 'milk' 293 | @promise.hoodie = @hoodie 294 | 295 | spyOn(@hoodie.share, "add").andReturn @hoodie.defer().resolve( {id: 'newShareId'} ) 296 | 297 | it "should save object returned by promise with {$sharedAt: 'shareId'}", -> 298 | Hoodie.Share::_storeShare.apply(@promise) 299 | expect(@hoodie.store.update).wasCalledWith 'task', '123', {$sharedAt: 'newShareId'} 300 | 301 | # _when "promise returns multiple objects", -> 302 | # beforeEach -> 303 | # @promise = @storeDefer.resolve [ 304 | # {type: 'task', id: '123', title: 'milk'} 305 | # {type: 'task', id: '456', title: 'milk'} 306 | # ] 307 | # @promise.hoodie = @hoodie 308 | 309 | # it "should update object returned by promise with $public: true", -> 310 | # Hoodie.Share::_storeShareAt.apply(@promise, ['shareId']) 311 | # expect(@hoodie.store.update).wasCalledWith 'task', '123', {$sharedAt: 'shareId'} 312 | # expect(@hoodie.store.update).wasCalledWith 'task', '456', {$sharedAt: 'shareId'} 313 | # /share() 314 | # /hoodie.store promise decorations --------------------------------------------------------------------------------