├── .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
#
6 | class Hoodie . Global
7 |
8 | constructor: (hoodie) ->
` return hoodie . open ( "global" ) `
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 #
2 |
3 | Hoodie.Errors =
4 |
#
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"
#
8 | INVALID_ARGUMENTS : (msg) ->
9 | new Error msg
#
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 | [](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 #
2 |
3 | class Hoodie . Email
#
4 | constructor : (@hoodie) ->
5 |
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 |
#
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 )
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+""+r.tag+">"};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
#
4 | class Hoodie . User
5 |
6 | constructor: (@hoodie) ->
@hoodie . store . decoratePromises
7 | publish : @_storePublish
8 | unpublish : @_storeUnpublish
` return this . api `
10 |
11 | #
12 | api : (userHash, options = {}) =>
13 | $ . extend options , prefix: '$public'
14 | @hoodie . open "user/ #{ userHash } /public" , options
_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 |
_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
Hoodie . extend 'user' , Hoodie . User
--------------------------------------------------------------------------------
/doc/core/config.html:
--------------------------------------------------------------------------------
1 | core/config #
2 |
3 | class Hoodie . Config
4 |
type : '$config'
5 | id : 'hoodie'
#
6 | constructor : (@hoodie, options = {}) ->
@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 |
#
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 |
#
28 | get : (key) ->
29 | @cache [ key ]
clear : =>
30 | @cache = {}
31 | @hoodie . store . remove @type , @id
32 |
33 |
#
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
#
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 |
one: (ev, callback) ->
23 | @bind ev , ->
24 | @unbind ( ev , arguments . callee )
25 | callback . apply ( @ , arguments )
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
#
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
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 ) {})
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 ) {})
share = hoodie ( "share/abc8320" )
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" })
share . connect ()
23 | share . disconnect ()
24 | share . pull ()
25 | share . push ()
26 | share . sync ()
share . on ( "event" , callback )
hoodie . open ( "share/abc8320" , {
27 | password : "secret"
28 | })
--------------------------------------------------------------------------------
/doc/core/account_remote.html:
--------------------------------------------------------------------------------
1 | core/account_remote
#
5 | class Hoodie . AccountRemote extends Hoodie . Remote
#
6 | constructor : (@hoodie, options = {}) ->
@name = @hoodie . account . db ()
7 |
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 : =>
15 | @hoodie . account . authenticate (). pipe =>
16 | @hoodie . on 'store:idle' , @push
17 | @push ()
18 | super
disconnect: =>
19 | @hoodie . unbind 'store:idle' , @push
20 | super
21 |
getSinceNr : (since) ->
23 | @hoodie . config . get ( '_remote.since' ) or 0
24 | setSinceNr : (since) ->
25 | @hoodie . config . set ( '_remote.since' , since )
push : (objects) =>
27 | objects = @hoodie . store . changedObjects () unless $ . isArray objects
28 | promise = super ( objects )
29 | promise . fail @hoodie . checkConnection
30 | return promise
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 |
trigger : (event, parameters...) ->
37 | @hoodie . trigger "remote: #{ event } " , parameters ...
_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
--------------------------------------------------------------------------------
Global