directed-edge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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