grendel-ruby 0.1.1
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/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE.md +26 -0
- data/README.md +192 -0
- data/Rakefile +48 -0
- data/TODO.md +8 -0
- data/VERSION +1 -0
- data/grendel-ruby.gemspec +89 -0
- data/lib/core_ext/hash.rb +17 -0
- data/lib/grendel.rb +16 -0
- data/lib/grendel/client.rb +63 -0
- data/lib/grendel/document.rb +27 -0
- data/lib/grendel/document_manager.rb +47 -0
- data/lib/grendel/link.rb +15 -0
- data/lib/grendel/link_manager.rb +30 -0
- data/lib/grendel/linked_document.rb +30 -0
- data/lib/grendel/linked_document_manager.rb +31 -0
- data/lib/grendel/user.rb +68 -0
- data/lib/grendel/user_manager.rb +31 -0
- data/spec/grendel/client_spec.rb +19 -0
- data/spec/grendel/document_manager_spec.rb +104 -0
- data/spec/grendel/document_spec.rb +23 -0
- data/spec/grendel/link_manager_spec.rb +80 -0
- data/spec/grendel/link_spec.rb +12 -0
- data/spec/grendel/linked_document_manager_spec.rb +91 -0
- data/spec/grendel/linked_document_spec.rb +23 -0
- data/spec/grendel/user_manager_spec.rb +89 -0
- data/spec/grendel/user_spec.rb +49 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +21 -0
- metadata +140 -0
@@ -0,0 +1,27 @@
|
|
1
|
+
module Grendel
|
2
|
+
class Document
|
3
|
+
attr_accessor :user, :name, :uri, :data, :content_type
|
4
|
+
|
5
|
+
def initialize(user, params)
|
6
|
+
params.symbolize_keys!
|
7
|
+
@user = user
|
8
|
+
@client = user.client
|
9
|
+
@name = params[:name]
|
10
|
+
@data = params[:data]
|
11
|
+
@content_type = params[:content_type]
|
12
|
+
@uri = params[:uri] ?
|
13
|
+
URI.parse(params[:uri]).path :
|
14
|
+
"/documents/" + @name # escape this?
|
15
|
+
end
|
16
|
+
|
17
|
+
# delete this document from Grendel
|
18
|
+
def delete
|
19
|
+
@user.delete(@uri)
|
20
|
+
end
|
21
|
+
|
22
|
+
# send link operations to the Link class
|
23
|
+
def links
|
24
|
+
LinkManager.new(self)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Grendel
|
2
|
+
class DocumentManager
|
3
|
+
|
4
|
+
def initialize(user)
|
5
|
+
@user = user
|
6
|
+
@base_uri = "/documents"
|
7
|
+
end
|
8
|
+
|
9
|
+
# list all documents
|
10
|
+
def list
|
11
|
+
response = @user.get(@base_uri)
|
12
|
+
response["documents"].map {|d| Document.new(@user, d) }
|
13
|
+
end
|
14
|
+
|
15
|
+
# retreive a document
|
16
|
+
def find(name)
|
17
|
+
response = @user.get(@base_uri + "/" + name)
|
18
|
+
params = {
|
19
|
+
:name => name,
|
20
|
+
:data => response.body,
|
21
|
+
:content_type => response.headers['content-type'].first
|
22
|
+
}
|
23
|
+
Document.new(@user, params)
|
24
|
+
end
|
25
|
+
|
26
|
+
# store a document, creating a new one if it doesn't exist, or replacing the existing one if it does
|
27
|
+
def store(name, data, content_type = nil)
|
28
|
+
# if the content type isn't provided, guess it or set it to a default
|
29
|
+
unless content_type
|
30
|
+
if mime_type = MIME::Types.type_for(name).first
|
31
|
+
content_type = mime_type.content_type
|
32
|
+
else
|
33
|
+
content_type = 'application/octet-stream'
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
response = @user.put(@base_uri + "/" + name, data, :raw_data => true, :headers => {'Content-Type' => content_type})
|
38
|
+
Document.new(@user, :name => name, :data => data, :content_type => content_type)
|
39
|
+
end
|
40
|
+
|
41
|
+
# delete the specified document from Grendel
|
42
|
+
def delete(name)
|
43
|
+
@user.delete(@base_uri + "/" + name)
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
data/lib/grendel/link.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
module Grendel
|
2
|
+
class Link
|
3
|
+
attr_accessor :document, :user, :uri
|
4
|
+
|
5
|
+
def initialize(document, user, params = {})
|
6
|
+
params.symbolize_keys!
|
7
|
+
@document = document
|
8
|
+
@user = user
|
9
|
+
@uri = params[:uri] ?
|
10
|
+
URI.parse(params[:uri]).path :
|
11
|
+
"/links/" + @user.id # escape this?
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Grendel
|
2
|
+
class LinkManager
|
3
|
+
|
4
|
+
def initialize(document)
|
5
|
+
@document = document
|
6
|
+
@base_uri = @document.uri + "/links"
|
7
|
+
end
|
8
|
+
|
9
|
+
# return links to this document
|
10
|
+
def list
|
11
|
+
response = @document.user.get(@base_uri)
|
12
|
+
response["links"].map do |link|
|
13
|
+
Link.new(@document, User.new(@document.user.client, link["user"]), :uri => link["uri"])
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# add a link to a user and return a Link object
|
18
|
+
def add(user_id)
|
19
|
+
# REVIEW: 2010-02-23 <brad@wesabe.com> -- what does Grendel return if the link already exists?
|
20
|
+
@document.user.put(@base_uri + "/" + user_id)
|
21
|
+
Link.new(@document, User.new(@document.user.client, :id => user_id))
|
22
|
+
end
|
23
|
+
|
24
|
+
# remove a link to a user
|
25
|
+
def remove(user_id)
|
26
|
+
# REVIEW: 2010-02-23 <brad@wesabe.com> -- what does Grendel return if the link didn't exist?
|
27
|
+
@document.user.delete(@base_uri + "/" + user_id)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Grendel
|
2
|
+
class LinkedDocument < Document
|
3
|
+
attr_accessor :linked_user, :owner
|
4
|
+
|
5
|
+
# create a new linked document
|
6
|
+
# user - linked user
|
7
|
+
# params:
|
8
|
+
# :name => document name
|
9
|
+
# :uri => linked document uri
|
10
|
+
# :owner => {
|
11
|
+
# :id => owner id
|
12
|
+
# :uri => owner uri
|
13
|
+
# }
|
14
|
+
def initialize(linked_user, params)
|
15
|
+
params.symbolize_keys!
|
16
|
+
@owner = User.new(linked_user.client, params[:owner])
|
17
|
+
super(@owner, params)
|
18
|
+
@linked_user = linked_user
|
19
|
+
@name = params[:name]
|
20
|
+
@uri = params[:uri] ?
|
21
|
+
URI.parse(params[:uri]).path :
|
22
|
+
["/linked-documents", @owner.id, name].join("/")
|
23
|
+
end
|
24
|
+
|
25
|
+
# delete this linked document
|
26
|
+
def delete
|
27
|
+
@linked_user.delete(@uri)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Grendel
|
2
|
+
class LinkedDocumentManager
|
3
|
+
def initialize(user)
|
4
|
+
@user = user
|
5
|
+
@base_uri = "/linked-documents"
|
6
|
+
end
|
7
|
+
|
8
|
+
# list this user's linked documents. Returns an array of LinkedDocument objects
|
9
|
+
def list
|
10
|
+
response = @user.get(@base_uri)
|
11
|
+
response["linked-documents"].map {|ld| LinkedDocument.new(@user, ld) }
|
12
|
+
end
|
13
|
+
|
14
|
+
# retreive a linked document
|
15
|
+
def find(owner_id, name)
|
16
|
+
response = @user.get([@base_uri, owner_id, name].join("/"))
|
17
|
+
params = {
|
18
|
+
:name => name,
|
19
|
+
:data => response.body,
|
20
|
+
:content_type => response.headers['content-type'].first,
|
21
|
+
:owner => { :id => owner_id }
|
22
|
+
}
|
23
|
+
LinkedDocument.new(@user, params)
|
24
|
+
end
|
25
|
+
|
26
|
+
# delete the linked document
|
27
|
+
def delete(owner_id, name)
|
28
|
+
@user.delete([@base_uri, owner_id, name].join("/"))
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
data/lib/grendel/user.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
module Grendel
|
2
|
+
class User
|
3
|
+
attr_accessor :id, :password, :uri
|
4
|
+
attr_reader :client, :modified_at, :created_at, :keys
|
5
|
+
|
6
|
+
# create a new Grendel::User object
|
7
|
+
# params:
|
8
|
+
# id
|
9
|
+
# uri
|
10
|
+
# password
|
11
|
+
def initialize(client, params)
|
12
|
+
params.symbolize_keys!
|
13
|
+
@client = client
|
14
|
+
@id = params[:id]
|
15
|
+
@uri = params[:uri] ?
|
16
|
+
URI.parse(params[:uri]).path :
|
17
|
+
"/users/" + @id # escape this?
|
18
|
+
@password = params[:password]
|
19
|
+
@modified_at = DateTime.parse(params[:"modified-at"]) if params[:"modified-at"]
|
20
|
+
@created_at = DateTime.parse(params[:"created-at"]) if params[:"created-at"]
|
21
|
+
@keys = params[:keys]
|
22
|
+
end
|
23
|
+
|
24
|
+
# return user's creds in the form required by HTTParty
|
25
|
+
def auth
|
26
|
+
{:basic_auth => {:username => id, :password => password}}
|
27
|
+
end
|
28
|
+
|
29
|
+
#
|
30
|
+
# methods to do authenticated client calls with the user's base_uri
|
31
|
+
#
|
32
|
+
def get(uri = "", options = {})
|
33
|
+
options.merge!(auth)
|
34
|
+
@client.get(@uri + uri, options)
|
35
|
+
end
|
36
|
+
|
37
|
+
def post(uri = "", data = {}, options = {})
|
38
|
+
options.merge!(auth)
|
39
|
+
@client.post(@uri + uri, data, options)
|
40
|
+
end
|
41
|
+
|
42
|
+
def put(uri = "", data = {}, options = {})
|
43
|
+
options.merge!(auth)
|
44
|
+
@client.put(@uri + uri, data, options)
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(uri = "", options = {})
|
48
|
+
options.merge!(auth)
|
49
|
+
@client.delete(@uri + uri, options)
|
50
|
+
end
|
51
|
+
|
52
|
+
# change the user's password
|
53
|
+
def change_password(new_password)
|
54
|
+
put("", {:password => new_password})
|
55
|
+
@password = new_password
|
56
|
+
end
|
57
|
+
|
58
|
+
# send documents calls to the DocumentManager
|
59
|
+
def documents
|
60
|
+
DocumentManager.new(self)
|
61
|
+
end
|
62
|
+
|
63
|
+
# send linked documents calls to the LinkedDocumentManager
|
64
|
+
def linked_documents
|
65
|
+
LinkedDocumentManager.new(self)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Grendel
|
2
|
+
class UserManager
|
3
|
+
|
4
|
+
def initialize(client)
|
5
|
+
@client = client
|
6
|
+
end
|
7
|
+
|
8
|
+
# return all Grendel users as Grendel::User objects
|
9
|
+
def list
|
10
|
+
response = @client.get("/users")
|
11
|
+
response["users"].map {|u| User.new(@client, u) }
|
12
|
+
end
|
13
|
+
|
14
|
+
# retrieve a user, optionally setting the password
|
15
|
+
def find(id, password = nil)
|
16
|
+
response = @client.get("/users/#{id}") # need to escape this
|
17
|
+
user = User.new(@client, response)
|
18
|
+
user.password = password
|
19
|
+
return user
|
20
|
+
end
|
21
|
+
|
22
|
+
# create a new user
|
23
|
+
def create(id, password)
|
24
|
+
params = {:id => id, :password => password}
|
25
|
+
response = @client.post("/users", params)
|
26
|
+
# TODO: strip protocol and host from uri
|
27
|
+
User.new(@client, params.merge(:uri => response.headers['location'].first))
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe "Grendel::Client" do
|
4
|
+
before do
|
5
|
+
@client = Grendel::Client.new("http://example.com")
|
6
|
+
end
|
7
|
+
|
8
|
+
describe "new method" do
|
9
|
+
it "should set the base_uri" do
|
10
|
+
@client.base_uri.should == "http://example.com"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "users method" do
|
15
|
+
it "should return a Grendel::UserManager" do
|
16
|
+
@client.users.class.should be(Grendel::UserManager)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe "Grendel::DocumentManager" do
|
4
|
+
before do
|
5
|
+
@client = Grendel::Client.new("http://grendel")
|
6
|
+
@user_id = "alice"
|
7
|
+
@password = "s3kret"
|
8
|
+
@user = Grendel::User.new(@client, :id => @user_id, :password => @password)
|
9
|
+
@base_uri = "#{@user_id}:#{@password}@grendel/users/#{@user_id}/documents"
|
10
|
+
end
|
11
|
+
|
12
|
+
describe "list" do
|
13
|
+
before do
|
14
|
+
stub_json_request(:get, @base_uri, %{{
|
15
|
+
"documents":[
|
16
|
+
{"name":"document1.txt",
|
17
|
+
"uri":"http://grendel/users/#{@user_id}/documents/document1.txt"},
|
18
|
+
{"name":"document2.txt",
|
19
|
+
"uri":"http://grendel/users/#{@user_id}/documents/document2.txt"}
|
20
|
+
]}})
|
21
|
+
end
|
22
|
+
|
23
|
+
it "should return an array of all documents" do
|
24
|
+
docs = @user.documents.list
|
25
|
+
docs.length.should == 2
|
26
|
+
docs[0].name.should == "document1.txt"
|
27
|
+
docs[0].uri.should == "/users/#{@user_id}/documents/document1.txt"
|
28
|
+
docs[1].name.should == "document2.txt"
|
29
|
+
docs[1].uri.should == "/users/#{@user_id}/documents/document2.txt"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe "find" do
|
34
|
+
before do
|
35
|
+
stub_json_request(:get, @base_uri + "/document1.txt", "yay for me", :content_type => "text/plain")
|
36
|
+
stub_json_request(:get, @base_uri + "/notfound.txt", "", :status => "404 Not Found")
|
37
|
+
end
|
38
|
+
|
39
|
+
it "should return the document" do
|
40
|
+
doc = @user.documents.find("document1.txt")
|
41
|
+
doc.name.should == "document1.txt"
|
42
|
+
doc.content_type.should == "text/plain"
|
43
|
+
doc.data.should == "yay for me"
|
44
|
+
end
|
45
|
+
|
46
|
+
it "should raise an exception if the document is not found" do
|
47
|
+
lambda {
|
48
|
+
@user.documents.find("notfound.txt")
|
49
|
+
}.should raise_error(Grendel::Client::HTTPException) {|error| error.message.should match("404")} # change to should == "404 Not Found" once WebMock supports status messages
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe "store" do
|
54
|
+
describe "a successful request" do
|
55
|
+
before do
|
56
|
+
stub_json_request(:put, @base_uri + "/new_document.txt", "", :status => "204 No Content")
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should send a properly-formatted request" do
|
60
|
+
@user.documents.store("new_document.txt", "top secret stuff", "text/plain")
|
61
|
+
params = { "id" => @user_id, "password" => @password }
|
62
|
+
request(:put, @base_uri + "/new_document.txt").
|
63
|
+
with(:body => "top secret stuff", :headers => {"Content-Type" => "text/plain"}).
|
64
|
+
should have_been_made.once
|
65
|
+
end
|
66
|
+
|
67
|
+
it "should guess the content type if not provided" do
|
68
|
+
@user.documents.store("new_document.txt", "top secret stuff")
|
69
|
+
params = { "id" => @user_id, "password" => @password }
|
70
|
+
request(:put, @base_uri + "/new_document.txt").
|
71
|
+
with(:body => "top secret stuff", :headers => {"Content-Type" => "text/plain"}).
|
72
|
+
should have_been_made.once
|
73
|
+
end
|
74
|
+
|
75
|
+
it "should default the content type to 'application/octet-stream' if unknown" do
|
76
|
+
stub_json_request(:put, @base_uri + "/new_document.-wtf-", "", :status => "204 No Content")
|
77
|
+
@user.documents.store("new_document.-wtf-", "top secret stuff")
|
78
|
+
params = { "id" => @user_id, "password" => @password }
|
79
|
+
request(:put, @base_uri + "/new_document.-wtf-").
|
80
|
+
with(:body => "top secret stuff", :headers => {"Content-Type" => "application/octet-stream"}).
|
81
|
+
should have_been_made.once
|
82
|
+
end
|
83
|
+
|
84
|
+
it "should return a document" do
|
85
|
+
doc = @user.documents.store("new_document.txt", "top secret stuff")
|
86
|
+
doc.name.should == "new_document.txt"
|
87
|
+
doc.data.should == "top secret stuff"
|
88
|
+
doc.content_type.should == "text/plain"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
describe "delete" do
|
94
|
+
before do
|
95
|
+
stub_json_request(:delete, @base_uri + "/document.txt", "", :status => "204 No Content")
|
96
|
+
@document = Grendel::Document.new(@user, :name => "document.txt")
|
97
|
+
end
|
98
|
+
|
99
|
+
it "should send a properly-formatted request" do
|
100
|
+
@user.documents.delete("document.txt")
|
101
|
+
request(:delete, @base_uri + "/document.txt").should have_been_made.once
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
describe "Grendel::Document" do
|
4
|
+
before do
|
5
|
+
@client = Grendel::Client.new("http://grendel")
|
6
|
+
@user_id = "alice"
|
7
|
+
@password = "s3kret"
|
8
|
+
@user = Grendel::User.new(@client, :id => @user_id, :password => @password)
|
9
|
+
@base_uri = "#{@user_id}:#{@password}@grendel/users/#{@user_id}/documents"
|
10
|
+
end
|
11
|
+
|
12
|
+
describe "delete" do
|
13
|
+
before do
|
14
|
+
stub_json_request(:delete, @base_uri + "/document.txt", "", :status => "204 No Content")
|
15
|
+
@document = Grendel::Document.new(@user, :name => "document.txt")
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should send a properly-formatted request" do
|
19
|
+
@document.delete
|
20
|
+
request(:delete, @base_uri + "/document.txt").should have_been_made.once
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
|
2
|
+
|
3
|
+
# - get a list of documents a user has linked to them
|
4
|
+
# docs = user.linked_documents.list
|
5
|
+
# - retrieve a linked document
|
6
|
+
# doc = user.linked_documents.find("owner_user_id", "document.txt")
|
7
|
+
# - delete a linked document
|
8
|
+
# user.linked_documents.delete("owner_user_id", "document.txt")
|
9
|
+
|
10
|
+
describe "Grendel::LinkManager" do
|
11
|
+
before do
|
12
|
+
@client = Grendel::Client.new("http://grendel")
|
13
|
+
@user_id = "alice"
|
14
|
+
@password = "s3kret"
|
15
|
+
@user = Grendel::User.new(@client, :id => @user_id, :password => @password)
|
16
|
+
@document = Grendel::Document.new(@user, :name => "document1.txt")
|
17
|
+
@uri = "#{@user_id}:#{@password}@grendel/users/#{@user_id}/documents/#{@document.name}/links"
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "list" do
|
21
|
+
before do
|
22
|
+
stub_json_request(:get, @uri, %{{
|
23
|
+
"links":[
|
24
|
+
{
|
25
|
+
"user":{
|
26
|
+
"id":"bob",
|
27
|
+
"uri":"http://grendel/users/bob"
|
28
|
+
},
|
29
|
+
"uri":"http://grendel/users/alice/documents/document1.txt/links/bob"
|
30
|
+
},
|
31
|
+
{
|
32
|
+
"user":{
|
33
|
+
"id":"carol",
|
34
|
+
"uri":"http://grendel/users/carol"
|
35
|
+
},
|
36
|
+
"uri":"http://grendel/users/alice/documents/document1.txt/links/carol"
|
37
|
+
}]
|
38
|
+
}})
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should list users with links to this document" do
|
42
|
+
links = @document.links.list
|
43
|
+
links.length.should == 2
|
44
|
+
links[0].user.id.should == "bob"
|
45
|
+
links[0].uri.should == "/users/alice/documents/document1.txt/links/bob"
|
46
|
+
links[1].user.id.should == "carol"
|
47
|
+
links[1].uri.should == "/users/alice/documents/document1.txt/links/carol"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "add" do
|
52
|
+
before do
|
53
|
+
@other_user_id = "bob"
|
54
|
+
stub_json_request(:put, @uri + "/" + @other_user_id, "")
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should send a properly-formatted request" do
|
58
|
+
@document.links.add(@other_user_id)
|
59
|
+
request(:put, @uri + "/" + @other_user_id).should have_been_made.once
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should return a Link object" do
|
63
|
+
link = @document.links.add(@other_user_id)
|
64
|
+
link.document.should == @document
|
65
|
+
link.user.id.should == @other_user_id
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
describe "remove" do
|
70
|
+
before do
|
71
|
+
@other_user_id = "bob"
|
72
|
+
stub_json_request(:delete, @uri + "/" + @other_user_id, "")
|
73
|
+
end
|
74
|
+
|
75
|
+
it "should send a properly-formatted request" do
|
76
|
+
@document.links.remove(@other_user_id)
|
77
|
+
request(:delete, @uri + "/" + @other_user_id).should have_been_made.once
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|