trelloapi 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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +11 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/.DS_Store +0 -0
- data/lib/trello.rb +163 -0
- data/lib/trello/.DS_Store +0 -0
- data/lib/trello/action.rb +68 -0
- data/lib/trello/association.rb +14 -0
- data/lib/trello/association_proxy.rb +42 -0
- data/lib/trello/attachment.rb +40 -0
- data/lib/trello/authorization.rb +187 -0
- data/lib/trello/basic_data.rb +132 -0
- data/lib/trello/board.rb +201 -0
- data/lib/trello/card.rb +456 -0
- data/lib/trello/checklist.rb +142 -0
- data/lib/trello/client.rb +120 -0
- data/lib/trello/comment.rb +62 -0
- data/lib/trello/configuration.rb +68 -0
- data/lib/trello/core_ext/array.rb +6 -0
- data/lib/trello/core_ext/hash.rb +6 -0
- data/lib/trello/core_ext/string.rb +6 -0
- data/lib/trello/cover_image.rb +8 -0
- data/lib/trello/has_actions.rb +9 -0
- data/lib/trello/item.rb +37 -0
- data/lib/trello/item_state.rb +30 -0
- data/lib/trello/json_utils.rb +64 -0
- data/lib/trello/label.rb +108 -0
- data/lib/trello/label_name.rb +31 -0
- data/lib/trello/list.rb +114 -0
- data/lib/trello/member.rb +112 -0
- data/lib/trello/multi_association.rb +12 -0
- data/lib/trello/net.rb +39 -0
- data/lib/trello/notification.rb +61 -0
- data/lib/trello/organization.rb +68 -0
- data/lib/trello/plugin_datum.rb +34 -0
- data/lib/trello/token.rb +36 -0
- data/lib/trello/webhook.rb +103 -0
- data/trello.gemspec +41 -0
- metadata +161 -0
@@ -0,0 +1,187 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require "oauth"
|
3
|
+
|
4
|
+
module Trello
|
5
|
+
module Authorization
|
6
|
+
|
7
|
+
AuthPolicy = Class.new do
|
8
|
+
def initialize(attrs = {}); end
|
9
|
+
|
10
|
+
def authorize(*args)
|
11
|
+
raise Trello::ConfigurationError, "Trello has not been configured to make authorized requests."
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class BasicAuthPolicy
|
16
|
+
class << self
|
17
|
+
attr_accessor :developer_public_key, :member_token
|
18
|
+
|
19
|
+
def authorize(request)
|
20
|
+
new.authorize(request)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_accessor :developer_public_key, :member_token
|
25
|
+
|
26
|
+
def initialize(attrs = {})
|
27
|
+
@developer_public_key = attrs[:developer_public_key] || self.class.developer_public_key
|
28
|
+
@member_token = attrs[:member_token] || self.class.member_token
|
29
|
+
end
|
30
|
+
|
31
|
+
def authorize(request)
|
32
|
+
the_uri = Addressable::URI.parse(request.uri)
|
33
|
+
existing_values = the_uri.query_values.nil? ? {} : the_uri.query_values
|
34
|
+
new_values = { key: @developer_public_key, token: @member_token }
|
35
|
+
the_uri.query_values = new_values.merge existing_values
|
36
|
+
|
37
|
+
Request.new request.verb, the_uri, request.headers, request.body
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
class Clock
|
42
|
+
def self.timestamp; Time.now.to_i; end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Nonce
|
46
|
+
def self.next
|
47
|
+
SecureRandom.hex()
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
OAuthCredential = Struct.new "OAuthCredential", :key, :secret
|
52
|
+
|
53
|
+
# Handles the OAuth connectivity to Trello.
|
54
|
+
#
|
55
|
+
# For 2-legged OAuth, do the following:
|
56
|
+
#
|
57
|
+
# OAuthPolicy.consumer_credential = OAuthCredential.new "public_key", "secret"
|
58
|
+
# OAuthPolicy.token = OAuthCredential.new "token_key", nil
|
59
|
+
#
|
60
|
+
# For 3-legged OAuth, do the following:
|
61
|
+
#
|
62
|
+
# OAuthPolicy.consumer_credential = OAuthCredential.new "public_key", "secret"
|
63
|
+
# OAuthPolicy.return_url = "http://your.site.com/path/to/receive/post"
|
64
|
+
# OAuthPolicy.callback = Proc.new do |request_token|
|
65
|
+
# DB.save(request_token.key, request_token.secret)
|
66
|
+
# redirect_to request_token.authorize_url
|
67
|
+
# end
|
68
|
+
#
|
69
|
+
# Then, recreate the request token given the request token key and secret you saved earlier,
|
70
|
+
# and the consumer, and pass that RequestToken instance the #get_access_token method, and
|
71
|
+
# store that in OAuthPolicy.token as a OAuthCredential.
|
72
|
+
class OAuthPolicy
|
73
|
+
class << self
|
74
|
+
attr_accessor :consumer_credential, :token, :return_url, :callback
|
75
|
+
|
76
|
+
def authorize(request)
|
77
|
+
new.authorize(request)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
attr_accessor :attributes
|
82
|
+
attr_accessor :consumer_credential, :token, :return_url, :callback
|
83
|
+
|
84
|
+
def initialize(attrs = {})
|
85
|
+
@consumer_key = attrs[:consumer_key]
|
86
|
+
@consumer_secret = attrs[:consumer_secret]
|
87
|
+
@oauth_token = attrs[:oauth_token]
|
88
|
+
@oauth_token_secret = attrs[:oauth_token_secret]
|
89
|
+
@return_url = attrs[:return_url] || self.class.return_url
|
90
|
+
@callback = attrs[:callback] || self.class.callback
|
91
|
+
end
|
92
|
+
|
93
|
+
def authorize(request)
|
94
|
+
unless consumer_credential
|
95
|
+
Trello.logger.error "The consumer_credential has not been supplied."
|
96
|
+
fail "The consumer_credential has not been supplied."
|
97
|
+
end
|
98
|
+
|
99
|
+
if token
|
100
|
+
request.headers = {"Authorization" => get_auth_header(request.uri, :get)}
|
101
|
+
request
|
102
|
+
else
|
103
|
+
consumer(return_url: return_url, callback_method: :postMessage)
|
104
|
+
request_token = consumer.get_request_token(oauth_callback: return_url)
|
105
|
+
callback.call request_token
|
106
|
+
return nil
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def consumer_credential
|
111
|
+
@consumer_credential ||= build_consumer_credential
|
112
|
+
end
|
113
|
+
|
114
|
+
def token
|
115
|
+
@token ||= build_token
|
116
|
+
end
|
117
|
+
|
118
|
+
def consumer_key
|
119
|
+
consumer_credential.key
|
120
|
+
end
|
121
|
+
|
122
|
+
def consumer_secret
|
123
|
+
consumer_credential.secret
|
124
|
+
end
|
125
|
+
|
126
|
+
def oauth_token
|
127
|
+
token.key
|
128
|
+
end
|
129
|
+
|
130
|
+
def oauth_token_secret
|
131
|
+
token.secret
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def build_consumer_credential
|
137
|
+
if @consumer_key && @consumer_secret
|
138
|
+
OAuthCredential.new @consumer_key, @consumer_secret
|
139
|
+
else
|
140
|
+
self.class.consumer_credential
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def build_token
|
145
|
+
if @oauth_token
|
146
|
+
OAuthCredential.new @oauth_token, @oauth_token_secret
|
147
|
+
else
|
148
|
+
self.class.token
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def consumer_params(params = {})
|
153
|
+
{
|
154
|
+
scheme: :header,
|
155
|
+
scope: 'read,write,account',
|
156
|
+
http_method: :get,
|
157
|
+
request_token_path: "https://trello.com/1/OAuthGetRequestToken",
|
158
|
+
authorize_path: "https://trello.com/1/OAuthAuthorizeToken",
|
159
|
+
access_token_path: "https://trello.com/1/OAuthGetAccessToken"
|
160
|
+
}.merge!(params)
|
161
|
+
end
|
162
|
+
|
163
|
+
def consumer(options = {})
|
164
|
+
@consumer ||= OAuth::Consumer.new(
|
165
|
+
consumer_credential.key,
|
166
|
+
consumer_credential.secret,
|
167
|
+
consumer_params(options)
|
168
|
+
)
|
169
|
+
end
|
170
|
+
|
171
|
+
def get_auth_header(url, verb, options = {})
|
172
|
+
request = Net::HTTP::Get.new Addressable::URI.parse(url).to_s
|
173
|
+
|
174
|
+
consumer.options[:signature_method] = 'HMAC-SHA1'
|
175
|
+
consumer.options[:nonce] = Nonce.next
|
176
|
+
consumer.options[:timestamp] = Clock.timestamp
|
177
|
+
consumer.options[:uri] = url
|
178
|
+
consumer.key = consumer_credential.key
|
179
|
+
consumer.secret = consumer_credential.secret
|
180
|
+
|
181
|
+
consumer.sign!(request, OAuth::Token.new(token.key, token.secret))
|
182
|
+
|
183
|
+
request['authorization']
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'active_support/inflector'
|
2
|
+
|
3
|
+
module Trello
|
4
|
+
class BasicData
|
5
|
+
include ActiveModel::Validations
|
6
|
+
include ActiveModel::Dirty
|
7
|
+
include ActiveModel::Serializers::JSON
|
8
|
+
|
9
|
+
include Trello::JsonUtils
|
10
|
+
|
11
|
+
class << self
|
12
|
+
def path_name
|
13
|
+
name.split("::").last.underscore
|
14
|
+
end
|
15
|
+
|
16
|
+
def find(id, params = {})
|
17
|
+
client.find(path_name, id, params)
|
18
|
+
end
|
19
|
+
|
20
|
+
def create(options)
|
21
|
+
client.create(path_name, options)
|
22
|
+
end
|
23
|
+
|
24
|
+
def save(options)
|
25
|
+
new(options).tap do |basic_data|
|
26
|
+
yield basic_data if block_given?
|
27
|
+
end.save
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse(response)
|
31
|
+
from_response(response).tap do |basic_data|
|
32
|
+
yield basic_data if block_given?
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def parse_many(response)
|
37
|
+
from_response(response).map do |data|
|
38
|
+
data.tap do |d|
|
39
|
+
yield d if block_given?
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.register_attributes(*names)
|
46
|
+
options = { readonly: [] }
|
47
|
+
options.merge!(names.pop) if names.last.kind_of? Hash
|
48
|
+
|
49
|
+
# Defines the attribute getter and setters.
|
50
|
+
class_eval do
|
51
|
+
define_method :attributes do
|
52
|
+
@attributes ||= names.reduce({}) { |hash, k| hash.merge(k.to_sym => nil) }
|
53
|
+
end
|
54
|
+
|
55
|
+
names.each do |key|
|
56
|
+
define_method(:"#{key}") { @attributes[key] }
|
57
|
+
|
58
|
+
unless options[:readonly].include?(key.to_sym)
|
59
|
+
define_method :"#{key}=" do |val|
|
60
|
+
send(:"#{key}_will_change!") unless val == @attributes[key]
|
61
|
+
@attributes[key] = val
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
define_attribute_methods names
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.one(name, opts = {})
|
71
|
+
class_eval do
|
72
|
+
define_method(:"#{name}") do |*args|
|
73
|
+
options = opts.dup
|
74
|
+
klass = options.delete(:via) || Trello.const_get(name.to_s.camelize)
|
75
|
+
ident = options.delete(:using) || :id
|
76
|
+
path = options.delete(:path)
|
77
|
+
|
78
|
+
if path
|
79
|
+
client.find(path, self.send(ident))
|
80
|
+
else
|
81
|
+
klass.find(self.send(ident))
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.many(name, opts = {})
|
88
|
+
class_eval do
|
89
|
+
define_method(:"#{name}") do |*args|
|
90
|
+
options = opts.dup
|
91
|
+
resource = options.delete(:in) || self.class.to_s.split("::").last.downcase.pluralize
|
92
|
+
klass = options.delete(:via) || Trello.const_get(name.to_s.singularize.camelize)
|
93
|
+
path = options.delete(:path) || name
|
94
|
+
params = options.merge(args[0] || {})
|
95
|
+
|
96
|
+
resources = client.find_many(klass, "/#{resource}/#{id}/#{path}", params)
|
97
|
+
MultiAssociation.new(self, resources).proxy
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def self.client
|
103
|
+
Trello.client
|
104
|
+
end
|
105
|
+
|
106
|
+
register_attributes :id, readonly: [ :id ]
|
107
|
+
|
108
|
+
attr_writer :client
|
109
|
+
|
110
|
+
def initialize(fields = {})
|
111
|
+
update_fields(fields)
|
112
|
+
end
|
113
|
+
|
114
|
+
def update_fields(fields)
|
115
|
+
raise NotImplementedError, "#{self.class} does not implement update_fields."
|
116
|
+
end
|
117
|
+
|
118
|
+
# Refresh the contents of our object.
|
119
|
+
def refresh!
|
120
|
+
self.class.find(id)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Two objects are equal if their _id_ methods are equal.
|
124
|
+
def ==(other)
|
125
|
+
id == other.id
|
126
|
+
end
|
127
|
+
|
128
|
+
def client
|
129
|
+
@client ||= self.class.client
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
data/lib/trello/board.rb
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
module Trello
|
2
|
+
|
3
|
+
# A board on Trello
|
4
|
+
#
|
5
|
+
# @!attribute [r] id
|
6
|
+
# @return [String]
|
7
|
+
# @!attribute [r] name
|
8
|
+
# @return [String]
|
9
|
+
# @!attribute [rw] description
|
10
|
+
# @return [String]
|
11
|
+
# @!attribute [rw] closed
|
12
|
+
# @return [Boolean]
|
13
|
+
# @!attribute [r] url
|
14
|
+
# @return [String]
|
15
|
+
# @!attribute [rw] organization_id
|
16
|
+
# @return [String] A 24-character hex string
|
17
|
+
# @!attribute [r] prefs
|
18
|
+
# @return [Hash] A 24-character hex string
|
19
|
+
class Board < BasicData
|
20
|
+
register_attributes :id, :name, :description, :closed, :starred, :url, :organization_id, :prefs, :last_activity_date,
|
21
|
+
readonly: [ :id, :url, :last_activity_date ]
|
22
|
+
validates_presence_of :id, :name
|
23
|
+
validates_length_of :name, in: 1..16384
|
24
|
+
validates_length_of :description, maximum: 16384
|
25
|
+
|
26
|
+
include HasActions
|
27
|
+
|
28
|
+
class << self
|
29
|
+
# Finds a board.
|
30
|
+
#
|
31
|
+
# @param [String] id Either the board's short ID (an alphanumeric string,
|
32
|
+
# found e.g. in the board's URL) or its long ID (a 24-character hex
|
33
|
+
# string.)
|
34
|
+
# @param [Hash] params
|
35
|
+
#
|
36
|
+
# @raise [Trello::Board] if a board with the given ID could not be found.
|
37
|
+
#
|
38
|
+
# @return [Trello::Board]
|
39
|
+
def find(id, params = {})
|
40
|
+
client.find(:board, id, params)
|
41
|
+
end
|
42
|
+
|
43
|
+
def create(fields)
|
44
|
+
data = {
|
45
|
+
'name' => fields[:name],
|
46
|
+
'desc' => fields[:description],
|
47
|
+
'closed' => fields[:closed] || false,
|
48
|
+
'starred' => fields[:starred] || false }
|
49
|
+
data.merge!('idOrganization' => fields[:organization_id]) if fields[:organization_id]
|
50
|
+
data.merge!('prefs' => fields[:prefs]) if fields[:prefs]
|
51
|
+
client.create(:board, data)
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Array<Trello::Board>] all boards for the current user
|
55
|
+
def all
|
56
|
+
from_response client.get("/members/#{Member.find(:me).username}/boards")
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def save
|
61
|
+
return update! if id
|
62
|
+
|
63
|
+
fields = { name: name }
|
64
|
+
fields.merge!(desc: description) if description
|
65
|
+
fields.merge!(idOrganization: organization_id) if organization_id
|
66
|
+
fields.merge!(flat_prefs)
|
67
|
+
|
68
|
+
from_response(client.post("/boards", fields))
|
69
|
+
end
|
70
|
+
|
71
|
+
def update!
|
72
|
+
fail "Cannot save new instance." unless self.id
|
73
|
+
|
74
|
+
@previously_changed = changes
|
75
|
+
@changed_attributes.clear
|
76
|
+
|
77
|
+
fields = {
|
78
|
+
name: attributes[:name],
|
79
|
+
description: attributes[:description],
|
80
|
+
closed: attributes[:closed],
|
81
|
+
starred: attributes[:starred],
|
82
|
+
idOrganization: attributes[:organization_id]
|
83
|
+
}
|
84
|
+
fields.merge!(flat_prefs)
|
85
|
+
|
86
|
+
from_response client.put("/boards/#{self.id}/", fields)
|
87
|
+
end
|
88
|
+
|
89
|
+
def update_fields(fields)
|
90
|
+
attributes[:id] = fields['id'] || fields[:id] if fields['id'] || fields[:id]
|
91
|
+
attributes[:name] = fields['name'] || fields[:name] if fields['name'] || fields[:name]
|
92
|
+
attributes[:description] = fields['desc'] || fields[:desc] if fields['desc'] || fields[:desc]
|
93
|
+
attributes[:closed] = fields['closed'] if fields.has_key?('closed')
|
94
|
+
attributes[:closed] = fields[:closed] if fields.has_key?(:closed)
|
95
|
+
attributes[:starred] = fields['starred'] if fields.has_key?('starred')
|
96
|
+
attributes[:starred] = fields[:starred] if fields.has_key?(:starred)
|
97
|
+
attributes[:url] = fields['url'] if fields['url']
|
98
|
+
attributes[:organization_id] = fields['idOrganization'] || fields[:organization_id] if fields['idOrganization'] || fields[:organization_id]
|
99
|
+
attributes[:prefs] = fields['prefs'] || fields[:prefs] || {}
|
100
|
+
attributes[:last_activity_date] = Time.iso8601(fields['dateLastActivity']) rescue nil
|
101
|
+
self
|
102
|
+
end
|
103
|
+
|
104
|
+
# @return [Boolean]
|
105
|
+
def closed?
|
106
|
+
attributes[:closed]
|
107
|
+
end
|
108
|
+
|
109
|
+
# @return [Boolean]
|
110
|
+
def starred?
|
111
|
+
attributes[:starred]
|
112
|
+
end
|
113
|
+
|
114
|
+
# @return [Boolean]
|
115
|
+
def has_lists?
|
116
|
+
lists.size > 0
|
117
|
+
end
|
118
|
+
|
119
|
+
# Find a card on this Board with the given ID.
|
120
|
+
# @return [Trello::Card]
|
121
|
+
def find_card(card_id)
|
122
|
+
Card.from_response client.get("/boards/#{self.id}/cards/#{card_id}")
|
123
|
+
end
|
124
|
+
|
125
|
+
# Add a member to this Board.
|
126
|
+
# type => [ :admin, :normal, :observer ]
|
127
|
+
def add_member(member, type = :normal)
|
128
|
+
client.put("/boards/#{self.id}/members/#{member.id}", { type: type })
|
129
|
+
end
|
130
|
+
|
131
|
+
# Remove a member of this Board.
|
132
|
+
def remove_member(member)
|
133
|
+
client.delete("/boards/#{self.id}/members/#{member.id}")
|
134
|
+
end
|
135
|
+
|
136
|
+
# Return all the cards on this board.
|
137
|
+
#
|
138
|
+
# This method, when called, can take a hash table with a filter key containing any
|
139
|
+
# of the following values:
|
140
|
+
# :filter => [ :none, :open, :closed, :all ] # default :open
|
141
|
+
many :cards, filter: :open
|
142
|
+
|
143
|
+
# Returns all the lists on this board.
|
144
|
+
#
|
145
|
+
# This method, when called, can take a hash table with a filter key containing any
|
146
|
+
# of the following values:
|
147
|
+
# :filter => [ :none, :open, :closed, :all ] # default :open
|
148
|
+
many :lists, filter: :open
|
149
|
+
|
150
|
+
# Returns an array of members who are associated with this board.
|
151
|
+
#
|
152
|
+
# This method, when called, can take a hash table with a filter key containing any
|
153
|
+
# of the following values:
|
154
|
+
# :filter => [ :none, :normal, :owners, :all ] # default :all
|
155
|
+
many :members, filter: :all
|
156
|
+
|
157
|
+
# Returns a reference to the organization this board belongs to.
|
158
|
+
one :organization, path: :organizations, using: :organization_id
|
159
|
+
|
160
|
+
def labels(params = {})
|
161
|
+
# Set the limit to as high as possible given there is no pagination in this API.
|
162
|
+
params[:limit] = 1000 unless params[:limit]
|
163
|
+
labels = Label.from_response client.get("/boards/#{id}/labels", params)
|
164
|
+
MultiAssociation.new(self, labels).proxy
|
165
|
+
end
|
166
|
+
|
167
|
+
def label_names
|
168
|
+
label_names = LabelName.from_response client.get("/boards/#{id}/labelnames")
|
169
|
+
MultiAssociation.new(self, label_names).proxy
|
170
|
+
end
|
171
|
+
|
172
|
+
# :nodoc:
|
173
|
+
def request_prefix
|
174
|
+
"/boards/#{id}"
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
# On creation
|
180
|
+
# https://trello.com/docs/api/board/#post-1-boards
|
181
|
+
# - permissionLevel
|
182
|
+
# - voting
|
183
|
+
# - comments
|
184
|
+
# - invitations
|
185
|
+
# - selfJoin
|
186
|
+
# - cardCovers
|
187
|
+
# - background
|
188
|
+
# - cardAging
|
189
|
+
#
|
190
|
+
# On update
|
191
|
+
# https://trello.com/docs/api/board/#put-1-boards-board-id
|
192
|
+
# Same as above plus:
|
193
|
+
# - calendarFeedEnabled
|
194
|
+
def flat_prefs
|
195
|
+
separator = id ? "/" : "_"
|
196
|
+
attributes[:prefs].inject({}) do |hash, (pref, v)|
|
197
|
+
hash.merge("prefs#{separator}#{pref}" => v)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|