directed-edge 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ coverage
2
+ rdoc
3
+ pkg
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (C) 2009, Directed Edge, Inc. <info@directededge.com>
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions
5
+ are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright
8
+ notice, this list of conditions and the following disclaimer.
9
+ 2. Redistributions in binary form must reproduce the above copyright
10
+ notice, this list of conditions and the following disclaimer in the
11
+ documentation and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
14
+ IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
15
+ OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
16
+ IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
17
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
18
+ NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
19
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
20
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
22
+ THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.rdoc ADDED
@@ -0,0 +1,17 @@
1
+ = directed-edge
2
+
3
+ Bindings for the Directed Edge Web-services API
4
+
5
+ == Note on Patches/Pull Requests
6
+
7
+ * Fork the project.
8
+ * Make your feature addition or bug fix.
9
+ * Add tests for it. This is important so I don't break it in a
10
+ future version unintentionally.
11
+ * Commit, do not mess with rakefile, version, or history.
12
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
13
+ * Send me a pull request. Bonus points for topic branches.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2009 Directed Edge, Inc. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,52 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "directed-edge"
8
+ gem.summary = "Bindings for the Directed Edge webservices API"
9
+ gem.description = "Bindings for the Directed Edge webservices API"
10
+ gem.email = "info@directededge.com"
11
+ gem.homepage = "http://developer.directededge.com/"
12
+ gem.authors = ["Directed Edge"]
13
+ gem.add_dependency "rest-client", ">= 0"
14
+ end
15
+ Jeweler::GemcutterTasks.new
16
+ rescue LoadError
17
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
18
+ end
19
+
20
+ require 'rake/testtask'
21
+ Rake::TestTask.new(:test) do |test|
22
+ test.libs << 'lib' << 'test'
23
+ test.pattern = 'test/**/test_*.rb'
24
+ test.verbose = true
25
+ end
26
+
27
+ begin
28
+ require 'rcov/rcovtask'
29
+ Rcov::RcovTask.new do |test|
30
+ test.libs << 'test'
31
+ test.pattern = 'test/**/test_*.rb'
32
+ test.verbose = true
33
+ end
34
+ rescue LoadError
35
+ task :rcov do
36
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
37
+ end
38
+ end
39
+
40
+ task :test => :check_dependencies
41
+
42
+ task :default => :test
43
+
44
+ require 'rake/rdoctask'
45
+ Rake::RDocTask.new do |rdoc|
46
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
47
+
48
+ rdoc.rdoc_dir = 'rdoc'
49
+ rdoc.title = "directed-edge #{version}"
50
+ rdoc.rdoc_files.include('README*')
51
+ rdoc.rdoc_files.include('lib/**/*.rb')
52
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,49 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{directed-edge}
8
+ s.version = "0.1.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Directed Edge"]
12
+ s.date = %q{2009-12-28}
13
+ s.description = %q{Bindings for the Directed Edge webservices API}
14
+ s.email = %q{info@directededge.com}
15
+ s.extra_rdoc_files = [
16
+ "LICENSE"
17
+ ]
18
+ s.files = [
19
+ ".gitignore",
20
+ "LICENSE",
21
+ "README.rdoc",
22
+ "Rakefile",
23
+ "VERSION",
24
+ "directed-edge.gemspec",
25
+ "examples/example_store.rb",
26
+ "lib/directed_edge.rb",
27
+ "test/helper.rb",
28
+ "test/test_directed_edge.rb"
29
+ ]
30
+ s.homepage = %q{http://developer.directededge.com/}
31
+ s.rdoc_options = ["--charset=UTF-8"]
32
+ s.require_paths = ["lib"]
33
+ s.rubygems_version = %q{1.3.5}
34
+ s.summary = %q{Bindings for the Directed Edge webservices API}
35
+
36
+ if s.respond_to? :specification_version then
37
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
38
+ s.specification_version = 3
39
+
40
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
41
+ s.add_runtime_dependency(%q<rest-client>, [">= 0"])
42
+ else
43
+ s.add_dependency(%q<rest-client>, [">= 0"])
44
+ end
45
+ else
46
+ s.add_dependency(%q<rest-client>, [">= 0"])
47
+ end
48
+ end
49
+
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/ruby
2
+
3
+ require 'rubygems'
4
+ require 'activerecord'
5
+ require 'directed_edge'
6
+
7
+ ActiveRecord::Base.establish_connection(:adapter => 'mysql',
8
+ :host => 'localhost',
9
+ :username => 'examplestore',
10
+ :password => 'password',
11
+ :database => 'examplestore')
12
+
13
+ class Customer < ActiveRecord::Base
14
+ end
15
+
16
+ class Product < ActiveRecord::Base
17
+ end
18
+
19
+ class Purchase < ActiveRecord::Base
20
+ end
21
+
22
+ class ExampleStore
23
+ def initialize
24
+ @database = DirectedEdge::Database.new('examplestore', 'password')
25
+ end
26
+
27
+ def export_from_mysql
28
+
29
+ # Use the handy Directed Edge XML exporter to collect store data up to this
30
+ # point
31
+
32
+ exporter = DirectedEdge::Exporter.new('examplestore.xml')
33
+
34
+ # Loop through every customer in the database
35
+
36
+ Customer.find(:all).each do |customer|
37
+
38
+ # Create a new item in the Directed Edge export file with the ID "customer12345"
39
+
40
+ item = DirectedEdge::Item.new(exporter.database, "customer#{customer.id}")
41
+
42
+ # Mark this item as a customer with a tag
43
+
44
+ item.add_tag('customer')
45
+
46
+ # Find all of the purchases for the current customer
47
+
48
+ purchases = Purchase.find(:all, :conditions => { :customer => customer.id })
49
+
50
+ # For each purchase create a link from the customer to that item of the form
51
+ # "product12345"
52
+
53
+ purchases.each { |purchase| item.link_to("product#{purchase.product}") }
54
+
55
+ # And now write the item to the export file
56
+
57
+ exporter.export(item)
58
+ end
59
+
60
+ # Now go through all of the products creating items for them
61
+
62
+ Product.find(:all).each do |product|
63
+
64
+ # Here we'll also use the form "product12345" for our products
65
+
66
+ item = DirectedEdge::Item.new(exporter.database, "product#{product.id}")
67
+
68
+ # And mark it as a product with a tag
69
+
70
+ item.add_tag('product')
71
+
72
+ # And export it to the file
73
+
74
+ exporter.export(item)
75
+ end
76
+
77
+ # We have to tell the exporter to clean up and finish up the file
78
+
79
+ exporter.finish
80
+ end
81
+
82
+ # Imports the file exported from the export method to the Directed Edge database
83
+
84
+ def import_to_directededge
85
+ @database.import('examplestore.xml')
86
+ end
87
+
88
+ # Creates a new customer in the Directed Edge database that corresponds to the
89
+ # given customer ID
90
+
91
+ def create_customer(id)
92
+ item = DirectedEdge::Item.new(@database, "customer#{id}")
93
+ item.add_tag('customer')
94
+ item.save
95
+ end
96
+
97
+ # Creates a new product in the Directed Edge database that corresponds to the
98
+ # given product ID
99
+
100
+ def create_product(id)
101
+ item = DirectedEdge::Item.new(@database, "product#{id}")
102
+ item.add_tag('product')
103
+ item.save
104
+ end
105
+
106
+ # Notes in the Directed Edge database that customer_id purchased product_id
107
+
108
+ def add_purchase(customer_id, product_id)
109
+ item = DirectedEdge::Item.new(@database, "customer#{customer_id}")
110
+ item.link_to("product#{product_id}")
111
+ item.save
112
+ end
113
+
114
+ # Returns a list of product IDs related to the given product ID
115
+
116
+ def related_products(product_id)
117
+ item = DirectedEdge::Item.new(@database, "product#{product_id}")
118
+ item.related(['product']).map { |product| product.sub('product', '').to_i }
119
+ end
120
+
121
+ # Returns a list of personalized recommendations for the given customer ID
122
+
123
+ def personalized_recommendations(customer_id)
124
+ item = DirectedEdge::Item.new(@database, "customer#{customer_id}")
125
+ item.recommended(['product']).map { |product| product.sub('product', '').to_i }
126
+ end
127
+ end
128
+
129
+ store = ExampleStore.new
130
+
131
+ # Export the contents of our MySQL database to XML
132
+
133
+ store.export_from_mysql
134
+
135
+ # Import that XML to the Directed Edge database
136
+
137
+ store.import_to_directededge
138
+
139
+ # Add a new customer
140
+
141
+ store.create_customer(500)
142
+
143
+ # Add a new product
144
+
145
+ store.create_product(2000)
146
+
147
+ # Set that user as having purchased that product
148
+
149
+ store.add_purchase(500, 2000)
150
+
151
+ # Find related products for the product with the ID 1 (in MySQL)
152
+
153
+ store.related_products(1).each do |product|
154
+ puts "Related products for product 1: #{product}"
155
+ end
156
+
157
+ # Find personalized recommendations for the user with the ID 1 (in MySQL)
158
+
159
+ store.personalized_recommendations(1).each do |product|
160
+ puts "Personalized recommendations for user 1: #{product}"
161
+ end
@@ -0,0 +1,672 @@
1
+ # Copyright (C) 2009 Directed Edge, Inc.
2
+ #
3
+ # Redistribution and use in source and binary forms, with or without
4
+ # modification, are permitted provided that the following conditions
5
+ # are met:
6
+ #
7
+ # 1. Redistributions of source code must retain the above copyright
8
+ # notice, this list of conditions and the following disclaimer.
9
+ # 2. Redistributions in binary form must reproduce the above copyright
10
+ # notice, this list of conditions and the following disclaimer in the
11
+ # documentation and/or other materials provided with the distribution.
12
+ #
13
+ # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
14
+ # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
15
+ # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
16
+ # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
17
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
18
+ # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
19
+ # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
20
+ # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
22
+ # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+ require 'rubygems'
25
+ require 'rest_client'
26
+ require 'rexml/document'
27
+ require 'cgi'
28
+
29
+ class Hash
30
+
31
+ # An extension to normalize tokens and strings of the form foo_bar to strings
32
+ # of fooBar as expected by the REST API.
33
+
34
+ def normalize!
35
+ each do |key, value|
36
+ if !key.is_a?(String)
37
+ delete(key)
38
+ key = key.to_s
39
+ store(key, value.to_s)
40
+ end
41
+ if key.match(/_\w/)
42
+ delete(key)
43
+ store(key.gsub(/_\w/) { |s| s[1, 1].upcase }, value.to_s)
44
+ elsif !value.is_a?(String)
45
+ store(key, value.to_s)
46
+ end
47
+ end
48
+ self
49
+ end
50
+ end
51
+
52
+ # The DirectedEdge module contains three classes:
53
+ #
54
+ # - Database - encapsulation of connection a database hosted by Directed Edge.
55
+ # - Exporter - simple mechanism for exporting data from existing data sources.
56
+ # - Item - item (user, product, page) in a Directed Edge database.
57
+
58
+ module DirectedEdge
59
+
60
+ # Base class used for Database and Item that has some basic resource
61
+ # grabbing functionality.
62
+
63
+ class Resource
64
+
65
+ private
66
+
67
+ # Reads an item from the database and puts it into an XML document.
68
+
69
+ def read_document(method='', params={})
70
+ method << '?' << params.map { |key, value| "#{URI.encode(key)}=#{URI.encode(value)}" }.join('&')
71
+ REXML::Document.new(@resource[method].get(:accept => 'text/xml'))
72
+ end
73
+
74
+ # Returns an array of the elements from the document matching the given
75
+ # element name.
76
+
77
+ def list_from_document(document, element)
78
+ values = []
79
+ document.elements.each("//#{element}") { |v| values.push(v.text) }
80
+ values
81
+ end
82
+
83
+ # Similar to list_from_document, but instead of a list of items for the given
84
+ # element returns a hash of key-value pairs (attributes), e.g.:
85
+ #
86
+ # 'item1' => { 'foo' => 'bar' }
87
+
88
+ def property_hash_from_document(document, element)
89
+ values = {}
90
+ document.elements.each("//#{element}") do |e|
91
+ values[e.text] = {}
92
+ e.attributes.each_attribute { |a| values[e.text][a.name] = a.value }
93
+ end
94
+ values
95
+ end
96
+
97
+ # Returns a hash of the elements from the document matching the given
98
+ # element name. If the specified attribute is present, its value will
99
+ # be assigned to the hash, otherwise the default value given will be
100
+ # used.
101
+
102
+ def hash_from_document(document, element, attribute, default=0)
103
+ values = {}
104
+ document.elements.each("//#{element}") do |v|
105
+ value = v.attribute(attribute).to_s || default
106
+ if value.empty?
107
+ values[v.text] = default
108
+ elsif value.to_i.to_s == value.to_s
109
+ values[v.text] = value.to_i
110
+ else
111
+ values[v.text] = value.to_s
112
+ end
113
+ end
114
+ values
115
+ end
116
+ end
117
+
118
+ # A Database is an encapsulation of a database being accessed via the Directed
119
+ # Edge web-services API. You can request database creation by visiting
120
+ # http://www.directededge.com and will recieve a user name and password which
121
+ # are then used to connect to your DirectedEdge::Database instance.
122
+ #
123
+ # Usually when getting started with a DirectedEdge database, users would like to
124
+ # import some pre-existing data, usually from their web application's database.
125
+ # The Database class has an import method which can be used to import data using
126
+ # Directed Edge's XML format. Files formatted in that way may be created with
127
+ # the Exporter.
128
+ #
129
+ # A database is typically instantiated via:
130
+ #
131
+ # database = DirectedEdge::Database.new('mydatabase', 'mypassword')
132
+
133
+ class Database < Resource
134
+
135
+ # The name of the database.
136
+
137
+ attr_reader :name
138
+
139
+ # The REST resource used for connecting to the database.
140
+
141
+ attr_reader :resource
142
+
143
+ # Creates a connection to a Directed Edge database. The name and password
144
+ # should have been provided when the account was created. The protocol
145
+ # parameter is optional and may be <tt>http</tt> or <tt>https</tt>.
146
+ # <tt>http</tt> is used by default as it is somewhat lower latency.
147
+
148
+ def initialize(name, password='', protocol='http')
149
+ @name = name
150
+ host = ENV['DIRECTEDEDGE_HOST'] || 'webservices.directededge.com'
151
+ @resource =
152
+ RestClient::Resource.new("#{protocol}://#{name}:#{password}@#{host}/api/v1/#{name}")
153
+ end
154
+
155
+ # Imports a Directed Edge XML file to the database.
156
+ #
157
+ # See http://developer.directededge.com for more information on the XML format or the
158
+ # Exporter for help on creating a file for importing.
159
+
160
+ def import(file_name)
161
+ @resource.put(File.read(file_name), :content_type => 'text/xml')
162
+ end
163
+
164
+ # Returns a set of recommendations for the set of items that is passed in in
165
+ # aggregate, commonly used to do recommendations for a basket of items.
166
+
167
+ def group_related(items=Set.new, tags=Set.new, params={})
168
+ (!items.is_a?(Array) || items.size < 1) and return []
169
+ params['items'] = items.to_a.join(',')
170
+ params['tags'] = tags.to_a.join(',')
171
+ params['union'] = true
172
+ params.normalize!
173
+ if params['includeProperties'] == 'true'
174
+ property_hash_from_document(read_document('related', params), 'related')
175
+ else
176
+ list_from_document(read_document('related', params), 'related')
177
+ end
178
+ end
179
+ end
180
+
181
+ # A very simple class for creating Directed Edge XML files or doing batch
182
+ # updates to a database. This can be done for example with:
183
+ #
184
+ # exporter = DirectedEdge::Exporter.new('mydatabase.xml')
185
+ # item = DirectedEdge::Item.new(exporter.database, 'product_1')
186
+ # item.add_tag('product')
187
+ # exporter.export(item)
188
+ # exporter.finish
189
+ #
190
+ # <tt>mydatabase.xml</tt> now contains:
191
+ #
192
+ # <?xml version="1.0" encoding="UTF-8"?>
193
+ # <directededge version="0.1">
194
+ # <item id='product_1'><tag>product</tag></item>
195
+ # </directededge>
196
+ #
197
+ # Which can then be imported to a database on the server with:
198
+ #
199
+ # database = DirectedEdge::Database.new('mydatabase', 'mypassword')
200
+ # database.import('mydatabase.xml')
201
+ #
202
+ # Alternatively, had the first line been:
203
+ #
204
+ # exporter = DirectedEdge::Exporter.new(some_database_object)
205
+ #
206
+ # Then newly created / modfied objects that on which export was called would be
207
+ # queued for a batch update to the database later.
208
+ #
209
+ # Items may also be exported from existing databases.
210
+
211
+ class Exporter
212
+
213
+ # Provides a dummy database for use when creating new items to be exported.
214
+
215
+ attr_reader :database
216
+
217
+ # Begins exporting a collection of items to the given destination. If the
218
+ # destination is a file existing contents will be overwritten. If the
219
+ # destination is an existing database object, updates will be queued until
220
+ # finish is called, at which point they will be uploaded to the webservices
221
+ # in batch.
222
+
223
+ def initialize(destination)
224
+ if destination.is_a?(String)
225
+ @database = Database.new('exporter')
226
+ @file = File.new(destination, 'w')
227
+ elsif destination.is_a?(Database)
228
+ @database = destination
229
+ @data = ""
230
+ else
231
+ raise TypeError.new("Exporter must be passed a file name or database object.")
232
+ end
233
+
234
+ write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n")
235
+ write("<directededge version=\"0.1\">\n")
236
+ end
237
+
238
+ # Exports the given item to the file passed to the constructor.
239
+
240
+ def export(item)
241
+ write("#{item.to_xml}\n")
242
+ end
243
+
244
+ # Writes a closing XML element to the document and closes the file.
245
+
246
+ def finish
247
+ write("</directededge>\n")
248
+ if !@file.nil?
249
+ @file.close
250
+ else
251
+ @database.resource['add'].put(@data)
252
+ end
253
+ end
254
+
255
+ private
256
+
257
+ def write(data)
258
+ if !@file.nil?
259
+ @file.write(data)
260
+ else
261
+ @data += data
262
+ end
263
+ end
264
+ end
265
+
266
+ # Represents an item in a Directed Edge database. Items can be products, pages
267
+ # or users, for instance. Usually items groups are differentiated from one
268
+ # another by a set of tags that are provided.
269
+ #
270
+ # For instance, a user in the Directed Edge database could be modeled as:
271
+ #
272
+ # user = DirectedEdge::Item.new(database, 'user_1')
273
+ # user.add_tag('user')
274
+ # user.save
275
+ #
276
+ # Similarly a product could be:
277
+ #
278
+ # product = DirectedEdge::Item.new(database, 'product_1')
279
+ # product.add_tag('product')
280
+ # product['price'] = '$42'
281
+ # product.save
282
+ #
283
+ # Note here that items have tags and properties. Tags are a free-form set of
284
+ # text identifiers that can be associated with an item, e.g. "user", "product",
285
+ # "page", "science fiction", etc.
286
+ #
287
+ # Properties are a set of key-value pairs associated with the item. For example,
288
+ # <tt>product['price'] = '$42'</tt>, or <tt>user['first name'] = 'Bob'</tt>.
289
+ #
290
+ # If we wanted to link the user to the product, for instance, indicating that the
291
+ # user had purchased the product we can use:
292
+ #
293
+ # user.link_to(product)
294
+ # user.save
295
+
296
+ class Item < Resource
297
+
298
+ # The unique item identifier used by the database and specified in the item's
299
+ # constructor.
300
+
301
+ attr_reader :id
302
+
303
+ # Creates a handle to an item in the DirectedEdge database which may be
304
+ # manipulated locally and then saved back to the database by calling save.
305
+
306
+ def initialize(database, id)
307
+ @database = database
308
+
309
+ @id = id
310
+ @links = {}
311
+ @tags = Set.new
312
+ @properties = {}
313
+
314
+ @links_to_remove = Set.new
315
+ @tags_to_remove = Set.new
316
+ @properties_to_remove = Set.new
317
+
318
+ @resource = @database.resource[URI.escape(@id)]
319
+ @cached = false
320
+ end
321
+
322
+ # Returns true if the other item is the same. The item given can either be
323
+ # a string or an item object.
324
+
325
+ def ==(other)
326
+ if other.is_a?(Item)
327
+ other.id == id
328
+ else
329
+ other.to_s == id
330
+ end
331
+ end
332
+
333
+ # Returns the item's ID.
334
+
335
+ def name
336
+ @id
337
+ end
338
+
339
+ # Creates an item if it does not already exist in the database or overwrites
340
+ # an existing item if one does.
341
+
342
+ def create(links={}, tags=Set.new, properties={})
343
+ @links = links
344
+ @tags = tags
345
+ @properties = properties
346
+
347
+ # Here we pretend that it's cached since this is now the authoritative
348
+ # copy of the values.
349
+
350
+ @cached = true
351
+ save
352
+ end
353
+
354
+ # Writes all changes to links, tags and properties back to the database and
355
+ # returns this item.
356
+
357
+ def save
358
+ if @cached
359
+ put(complete_document)
360
+ else
361
+
362
+ # The web services API allows to add or remove things incrementally.
363
+ # Since we're not in the cached case, let's check to see which action(s)
364
+ # are appropriate.
365
+
366
+ put(complete_document, 'add')
367
+
368
+ if !@links_to_remove.empty? || !@tags_to_remove.empty? || !@properties_to_remove.empty?
369
+ put(removal_document, 'remove')
370
+ @links_to_remove.clear
371
+ @tags_to_remove.clear
372
+ @properties_to_remove.clear
373
+ end
374
+ end
375
+ self
376
+ end
377
+
378
+ # Reloads (or loads) the item from the database. Any unsaved changes will
379
+ # will be discarded.
380
+
381
+ def reload
382
+ document = read_document
383
+
384
+ @links = hash_from_document(document, 'link', 'weight')
385
+ @tags = Set.new(list_from_document(document, 'tag'))
386
+ @properties = {}
387
+
388
+ @links_to_remove.clear
389
+ @tags_to_remove.clear
390
+ @properties_to_remove.clear
391
+
392
+ document.elements.each('//property') do |element|
393
+ @properties[element.attribute('name').value] = element.text
394
+ end
395
+ @cached = true
396
+ end
397
+
398
+ # Returns a set of items that are linked to from this item.
399
+
400
+ def links
401
+ read
402
+ @links
403
+ end
404
+
405
+ # Returns a set containing all of this item's tags.
406
+
407
+ def tags
408
+ read
409
+ @tags
410
+ end
411
+
412
+ # Returns a hash of all of this item's properties.
413
+
414
+ def properties
415
+ read
416
+ @properties
417
+ end
418
+
419
+ # Returns the property for the name specified.
420
+
421
+ def [](property_name)
422
+ read
423
+ @properties[property_name]
424
+ end
425
+
426
+ # Assigns value to the given property_name.
427
+ #
428
+ # This will not be written back to the database until save is called.
429
+
430
+ def []=(property_name, value)
431
+ @properties_to_remove.delete(property_name)
432
+ @properties[property_name] = value
433
+ end
434
+
435
+ # Remove the given property_name.
436
+
437
+ def clear_property(property_name)
438
+ if !@cached
439
+ @properties_to_remove.add(property_name)
440
+ end
441
+ @properties.delete(property_name)
442
+ end
443
+
444
+ # Removes an item from the database, including deleting all links to and
445
+ # from this item.
446
+
447
+ def destroy
448
+ @resource.delete
449
+ end
450
+
451
+ # Creates a link from this item to other.
452
+ #
453
+ # Weighted links are typically used to encode ratings. For instance, if
454
+ # a user has rated a given product that can be specified via:
455
+ #
456
+ # user = DirectedEdge::Item(database, 'user_1')
457
+ # product = DirectedEdge::Item(database, 'product_1') # preexisting item
458
+ # user.link_to(product, 5)
459
+ # user.save
460
+ #
461
+ # If no link is specified then a tradtional, unweighted link will be
462
+ # created. This is typical to, for instance, incidate a purchase or click
463
+ # from a user to a page or item.
464
+ #
465
+ # Weights may be in the range of 1 to 10.
466
+ #
467
+ # Note that 'other' must exist in the database or must be saved before this
468
+ # item is saved. Otherwise the link will be ignored as the engine tries
469
+ # to detect 'broken' links that do not terminate at a valid item.
470
+
471
+ def link_to(other, weight=0)
472
+ if weight < 0 || weight > 10
473
+ raise RangeError
474
+ end
475
+ @links_to_remove.delete(other)
476
+ @links[other.to_s] = weight
477
+ end
478
+
479
+ # Deletes a link from this item to other.
480
+ #
481
+ # The changes will not be reflected in the database until save is called.
482
+
483
+ def unlink_from(other)
484
+ if !@cached
485
+ @links_to_remove.add(other.to_s)
486
+ end
487
+ @links.delete(other.to_s)
488
+ end
489
+
490
+ # If there is a link for "other" then it returns the weight for the given
491
+ # item. Zero indicates that no weight is assigned.
492
+
493
+ def weight_for(other)
494
+ read
495
+ @links[other.to_s]
496
+ end
497
+
498
+ # Adds a tag to this item.
499
+ #
500
+ # The changes will not be reflected in the database until save is called.
501
+
502
+ def add_tag(tag)
503
+ @tags_to_remove.delete(tag)
504
+ @tags.add(tag)
505
+ end
506
+
507
+ # Removes a tag from this item.
508
+ #
509
+ # The changes will not be reflected in the database until save is called.
510
+
511
+ def remove_tag(tag)
512
+ if !@cached
513
+ @tags_to_remove.add(tag)
514
+ end
515
+ @tags.delete(tag)
516
+ end
517
+
518
+ # Returns the list of items related to this one. Unlike "recommended" this
519
+ # may include items which are directly linked from this item. If any tags
520
+ # are specified, only items which have one or more of the specified tags
521
+ # will be returned.
522
+ #
523
+ # Parameters that may be passed in include:
524
+ # - :exclude_linked (true / false)
525
+ # - :max_results (integer)
526
+ #
527
+ # This will not reflect any unsaved changes to items.
528
+
529
+ def related(tags=Set.new, params={})
530
+ params.normalize!
531
+ params['tags'] = tags.to_a.join(',')
532
+ if params['includeProperties'] == 'true'
533
+ property_hash_from_document(read_document('related', params), 'related')
534
+ else
535
+ list_from_document(read_document('related', params), 'related')
536
+ end
537
+ end
538
+
539
+ # Returns the list of items recommended for this item, usually a user.
540
+ # Unlike "related" this does not include items linked from this item. If
541
+ # any tags are specified, only items which have one or more of the specified
542
+ # tags will be returned.
543
+ #
544
+ # Parameters that may be passed in include:
545
+ # - :exclude_linked (true / false)
546
+ # - :max_results (integer)
547
+ #
548
+ # This will not reflect any unsaved changes to items.
549
+
550
+ def recommended(tags=Set.new, params={})
551
+ params.normalize!
552
+ params['tags'] = tags.to_a.join(',')
553
+ params.key?('excludeLinked') || params['excludeLinked'] = 'true'
554
+ if params['includeProperties'] == 'true'
555
+ property_hash_from_document(read_document('recommended', params), 'recommended')
556
+ else
557
+ list_from_document(read_document('recommended', params), 'recommended')
558
+ end
559
+ end
560
+
561
+ # Returns the ID of the item.
562
+
563
+ def to_s
564
+ @id
565
+ end
566
+
567
+ # Returns an XML representation of the item as a string not including the
568
+ # usual document regalia, e.g. starting with <item> (used for exporting the
569
+ # item to a file)
570
+
571
+ def to_xml
572
+ insert_item(REXML::Document.new).to_s
573
+ end
574
+
575
+ private
576
+
577
+ # Reads the tags / links / properties from the server if they are not
578
+ # already cached.
579
+
580
+ def read
581
+ if !@cached
582
+ begin
583
+ document = read_document
584
+ @links.merge!(hash_from_document(document, 'link', 'weight'))
585
+ @tags.merge(list_from_document(document, 'tag'))
586
+
587
+ document.elements.each('//property') do |element|
588
+ name = element.attribute('name').value
589
+ if !@properties.has_key?(name)
590
+ @properties[name] = element.text
591
+ end
592
+ end
593
+
594
+ @links_to_remove.each { |link| @links.delete(link) }
595
+ @tags_to_remove.each { |tag| @tags.delete(tag) }
596
+ @properties_to_remove.each { |property| @properties.delete(property) }
597
+
598
+ @links_to_remove.clear
599
+ @tags_to_remove.clear
600
+ @properties_to_remove.clear
601
+
602
+ @cached = true
603
+ rescue
604
+ puts "Couldn't read \"#{@id}\" from the database."
605
+ end
606
+ end
607
+ end
608
+
609
+ # Uploads the changes to the Directed Edge database. The optional method
610
+ # parameter may be used for either add or remove which do only incremental
611
+ # updates to the item.
612
+
613
+ def put(document, method='')
614
+ @resource[method].put(document.to_s, :content_type => 'text/xml')
615
+ end
616
+
617
+ # Creates a document for an entire item including the links, tags and
618
+ # properties.
619
+
620
+ def complete_document
621
+ document = REXML::Document.new
622
+ insert_item(document)
623
+ end
624
+
625
+ def removal_document
626
+ item = setup_document(REXML::Document.new)
627
+ @links_to_remove.each { |link| item.add_element('link').add_text(link.to_s) }
628
+ @tags_to_remove.each { |tag| item.add_element('tag').add_text(tag.to_s) }
629
+ @properties_to_remove.each do |property|
630
+ item.add_element('property').add_attribute('name', property.to_s)
631
+ end
632
+ item
633
+ end
634
+
635
+ def insert_item(document)
636
+ item = setup_document(document)
637
+ @links.each do |link, weight|
638
+ element = item.add_element('link')
639
+ if weight != 0
640
+ element.add_attribute('weight', weight.to_s)
641
+ end
642
+ element.add_text(link.to_s)
643
+ end
644
+ @tags.each { |tag| item.add_element('tag').add_text(tag.to_s) }
645
+ @properties.each do |key, value|
646
+ property = item.add_element('property')
647
+ property.add_attribute('name', key.to_s)
648
+ property.add_text(value.to_s)
649
+ end
650
+ item
651
+ end
652
+
653
+ # Creates a skeleton of an XML document for a given item.
654
+
655
+ def item_document(element, value)
656
+ document = REXML::Document.new
657
+ item = setup_document(document)
658
+ item.add_element(element).add_text(value.to_s)
659
+ document
660
+ end
661
+
662
+ # Sets up an existing XML document with the skeleton Directed Edge elements.
663
+
664
+ def setup_document(document)
665
+ directededge = document.add_element('directededge')
666
+ directededge.add_attribute('version', '0.1')
667
+ item = directededge.add_element('item')
668
+ item.add_attribute('id', @id.to_s)
669
+ item
670
+ end
671
+ end
672
+ end