ruote-extras 0.9.18
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/lib/openwfe/extras/engine/db_persisted_engine.rb +100 -0
- data/lib/openwfe/extras/expool/dberrorjournal.rb +189 -0
- data/lib/openwfe/extras/expool/dbexpstorage.rb +353 -0
- data/lib/openwfe/extras/listeners/sqslisteners.rb +146 -0
- data/lib/openwfe/extras/misc/activityfeed.rb +264 -0
- data/lib/openwfe/extras/misc/basecamp.rb +485 -0
- data/lib/openwfe/extras/participants/activeparticipants.rb +739 -0
- data/lib/openwfe/extras/participants/atomfeed_participants.rb +174 -0
- data/lib/openwfe/extras/participants/atompub_participants.rb +268 -0
- data/lib/openwfe/extras/participants/basecamp_participants.rb +87 -0
- data/lib/openwfe/extras/participants/csvparticipants.rb +127 -0
- data/lib/openwfe/extras/participants/sqsparticipants.rb +125 -0
- data/lib/openwfe/extras/participants/twitterparticipants.rb +176 -0
- metadata +70 -0
@@ -0,0 +1,146 @@
|
|
1
|
+
#
|
2
|
+
#--
|
3
|
+
# Copyright (c) 2007-2008, John Mettraux, OpenWFE.org
|
4
|
+
# All rights reserved.
|
5
|
+
#
|
6
|
+
# Redistribution and use in source and binary forms, with or without
|
7
|
+
# modification, are permitted provided that the following conditions are met:
|
8
|
+
#
|
9
|
+
# . Redistributions of source code must retain the above copyright notice, this
|
10
|
+
# list of conditions and the following disclaimer.
|
11
|
+
#
|
12
|
+
# . Redistributions in binary form must reproduce the above copyright notice,
|
13
|
+
# this list of conditions and the following disclaimer in the documentation
|
14
|
+
# and/or other materials provided with the distribution.
|
15
|
+
#
|
16
|
+
# . Neither the name of the "OpenWFE" nor the names of its contributors may be
|
17
|
+
# used to endorse or promote products derived from this software without
|
18
|
+
# specific prior written permission.
|
19
|
+
#
|
20
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
21
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
22
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
23
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
24
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
25
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
26
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
27
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
28
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
29
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
30
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
31
|
+
#++
|
32
|
+
#
|
33
|
+
|
34
|
+
#
|
35
|
+
# "made in Japan"
|
36
|
+
#
|
37
|
+
# John Mettraux at openwfe.org
|
38
|
+
#
|
39
|
+
|
40
|
+
require 'yaml'
|
41
|
+
require 'base64'
|
42
|
+
require 'monitor'
|
43
|
+
|
44
|
+
require 'openwfe/service'
|
45
|
+
require 'openwfe/listeners/listener'
|
46
|
+
|
47
|
+
#require 'rubygems'
|
48
|
+
require 'rufus/sqs' # gem 'rufus-sqs'
|
49
|
+
|
50
|
+
|
51
|
+
module OpenWFE
|
52
|
+
module Extras
|
53
|
+
|
54
|
+
#
|
55
|
+
# Polls an Amazon SQS queue for workitems
|
56
|
+
#
|
57
|
+
# Workitems can be instances of InFlowWorkItem or LaunchItem.
|
58
|
+
#
|
59
|
+
# require 'openwfe/extras/listeners/sqslisteners'
|
60
|
+
#
|
61
|
+
# ql = OpenWFE::SqsListener("workqueue1", engine.application_context)
|
62
|
+
#
|
63
|
+
# engine.add_workitem_listener(ql, "2m30s")
|
64
|
+
# #
|
65
|
+
# # thus, the engine will poll our "workqueue1" SQS queue
|
66
|
+
# # every 2 minutes and 30 seconds
|
67
|
+
#
|
68
|
+
class SqsListener < Service
|
69
|
+
|
70
|
+
include MonitorMixin
|
71
|
+
include WorkItemListener
|
72
|
+
include Rufus::Schedulable
|
73
|
+
|
74
|
+
#
|
75
|
+
# The name of the Amazon SQS whom this listener cares for
|
76
|
+
#
|
77
|
+
attr_reader :queue_name
|
78
|
+
|
79
|
+
def initialize (queue_name, application_context)
|
80
|
+
|
81
|
+
@queue_name = queue_name.to_s
|
82
|
+
|
83
|
+
service_name = "#{self.class}::#{@queue_name}"
|
84
|
+
|
85
|
+
super service_name, application_context
|
86
|
+
|
87
|
+
linfo { "new() queue is '#{@queue_name}'" }
|
88
|
+
end
|
89
|
+
|
90
|
+
#
|
91
|
+
# polls the SQS for incoming messages
|
92
|
+
#
|
93
|
+
def trigger (params)
|
94
|
+
synchronize do
|
95
|
+
|
96
|
+
ldebug { "trigger()" }
|
97
|
+
|
98
|
+
qs = Rufus::SQS::QueueService.new
|
99
|
+
|
100
|
+
qs.create_queue @queue_name
|
101
|
+
# just to be sure it is there
|
102
|
+
|
103
|
+
while true
|
104
|
+
|
105
|
+
l = qs.get_messages(
|
106
|
+
@queue_name, :timeout => 0, :count => 255)
|
107
|
+
|
108
|
+
break if l.length < 1
|
109
|
+
|
110
|
+
l.each do |msg|
|
111
|
+
|
112
|
+
o = decode_object msg
|
113
|
+
|
114
|
+
handle_item o
|
115
|
+
|
116
|
+
msg.delete
|
117
|
+
|
118
|
+
ldebug do
|
119
|
+
"trigger() " +
|
120
|
+
"handled successfully msg #{msg.message_id}"
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
#
|
128
|
+
# Extracts a workitem from the message's body.
|
129
|
+
#
|
130
|
+
# By default, this listeners assumes the workitem is stored in
|
131
|
+
# its "hash form" (not directly as a Ruby InFlowWorkItem instance).
|
132
|
+
#
|
133
|
+
# LaunchItem instances (as hash as well) are also accepted.
|
134
|
+
#
|
135
|
+
def decode_object (message)
|
136
|
+
|
137
|
+
o = Base64.decode64 message.message_body
|
138
|
+
o = YAML.load o
|
139
|
+
o = OpenWFE::workitem_from_h o
|
140
|
+
o
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
@@ -0,0 +1,264 @@
|
|
1
|
+
#
|
2
|
+
#--
|
3
|
+
# Copyright (c) 2007-2008, John Mettraux, OpenWFE.org
|
4
|
+
# All rights reserved.
|
5
|
+
#
|
6
|
+
# Redistribution and use in source and binary forms, with or without
|
7
|
+
# modification, are permitted provided that the following conditions are met:
|
8
|
+
#
|
9
|
+
# . Redistributions of source code must retain the above copyright notice, this
|
10
|
+
# list of conditions and the following disclaimer.
|
11
|
+
#
|
12
|
+
# . Redistributions in binary form must reproduce the above copyright notice,
|
13
|
+
# this list of conditions and the following disclaimer in the documentation
|
14
|
+
# and/or other materials provided with the distribution.
|
15
|
+
#
|
16
|
+
# . Neither the name of the "OpenWFE" nor the names of its contributors may be
|
17
|
+
# used to endorse or promote products derived from this software without
|
18
|
+
# specific prior written permission.
|
19
|
+
#
|
20
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
21
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
22
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
23
|
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
24
|
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
25
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
26
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
27
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
28
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
29
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
30
|
+
# POSSIBILITY OF SUCH DAMAGE.
|
31
|
+
#++
|
32
|
+
#
|
33
|
+
|
34
|
+
#
|
35
|
+
# "made in Japan"
|
36
|
+
#
|
37
|
+
# John Mettraux at openwfe.org
|
38
|
+
#
|
39
|
+
|
40
|
+
#
|
41
|
+
# this participant requires atom-tools from
|
42
|
+
#
|
43
|
+
# http://code.necronomicorp.com/trac/atom-tools
|
44
|
+
#
|
45
|
+
# atom-tools' license is X11/MIT
|
46
|
+
#
|
47
|
+
|
48
|
+
#require 'monitor'
|
49
|
+
|
50
|
+
#require 'rubygems'
|
51
|
+
require 'atom/collection' # gem 'atoom-tools'
|
52
|
+
|
53
|
+
require 'openwfe/service'
|
54
|
+
|
55
|
+
|
56
|
+
|
57
|
+
module OpenWFE::Extras
|
58
|
+
|
59
|
+
#
|
60
|
+
# A workitem event as kept
|
61
|
+
#
|
62
|
+
class Entry
|
63
|
+
|
64
|
+
attr_accessor :id
|
65
|
+
attr_accessor :updated
|
66
|
+
|
67
|
+
attr_accessor :participant_name
|
68
|
+
attr_accessor :upon
|
69
|
+
attr_accessor :workitem
|
70
|
+
|
71
|
+
def initialize
|
72
|
+
|
73
|
+
@update = Time.now
|
74
|
+
end
|
75
|
+
|
76
|
+
def as_atom_entry
|
77
|
+
|
78
|
+
e = Atom::Entry.new
|
79
|
+
|
80
|
+
e.id = @id
|
81
|
+
e.updated = @updated
|
82
|
+
|
83
|
+
e
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
#
|
88
|
+
# This feed registers as an observer to the ParticipantMap.
|
89
|
+
# Each time a workitem is delivered to a participant or comes back from/for
|
90
|
+
# it, an AtomEntry is generated.
|
91
|
+
# The get_feed() method produces an atom feed of participant activity.
|
92
|
+
#
|
93
|
+
class ActivityFeedService
|
94
|
+
#include MonitorMixin
|
95
|
+
include OpenWFE::ServiceMixin
|
96
|
+
include OpenWFE::OwfeServiceLocator
|
97
|
+
|
98
|
+
|
99
|
+
attr_accessor :max_item_count
|
100
|
+
|
101
|
+
|
102
|
+
#
|
103
|
+
# This service is generally tied to an engine by doing :
|
104
|
+
#
|
105
|
+
# engine.init_service 'activityFeed', ActivityFeedService
|
106
|
+
#
|
107
|
+
# The init_service() will take care of calling the constructor
|
108
|
+
# implemented here.
|
109
|
+
#
|
110
|
+
def initialize (service_name, application_context)
|
111
|
+
|
112
|
+
super()
|
113
|
+
|
114
|
+
service_init service_name, application_context
|
115
|
+
|
116
|
+
@entries = []
|
117
|
+
@max_item_count = 100
|
118
|
+
|
119
|
+
get_participant_map.add_observer ".*", self
|
120
|
+
end
|
121
|
+
|
122
|
+
#
|
123
|
+
# This is the method call by the expression pool each time a
|
124
|
+
# workitem reaches a participant.
|
125
|
+
#
|
126
|
+
def call (channel, *args)
|
127
|
+
|
128
|
+
#ldebug "call() c '#{channel}' entries count : #{@entries.size}"
|
129
|
+
|
130
|
+
e = Entry.new
|
131
|
+
|
132
|
+
e.participant_name = channel
|
133
|
+
e.upon = args[0]
|
134
|
+
e.workitem = args[1].dup
|
135
|
+
e.updated = Time.now
|
136
|
+
|
137
|
+
e.id = \
|
138
|
+
"#{e.workitem.participant_name} - #{e.upon} " +
|
139
|
+
"#{e.workitem.fei.workflow_instance_id}--" +
|
140
|
+
"#{e.workitem.fei.expression_id}"
|
141
|
+
|
142
|
+
@entries << e
|
143
|
+
|
144
|
+
while @entries.length > @max_item_count
|
145
|
+
@entries.delete_at 0
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
#
|
150
|
+
# Returns an Atom feed of all the workitem activity for the
|
151
|
+
# participants whose name matches the given regular expression.
|
152
|
+
#
|
153
|
+
# Options :
|
154
|
+
#
|
155
|
+
# [:upon] can be set to either nil, :apply, :reply. :apply states
|
156
|
+
# that the returned feed should only contain entries about
|
157
|
+
# participant getting applied, :reply only about
|
158
|
+
# participant replying.
|
159
|
+
# [:feed_uri] the URI for the feed. Defaults to
|
160
|
+
# 'http://localhost/feed'
|
161
|
+
# [:feed_title] the title for the feed. Defaults to
|
162
|
+
# 'OpenWFEru engine activity feed'
|
163
|
+
# [:format] yaml|text|json
|
164
|
+
#
|
165
|
+
#
|
166
|
+
def get_feed (participant_regex, options={})
|
167
|
+
|
168
|
+
participant_regex = Regexp.compile(participant_regex) \
|
169
|
+
if participant_regex.is_a?(String)
|
170
|
+
|
171
|
+
upon = options[:upon]
|
172
|
+
feed_uri = options[:feed_uri] || "http://localhost/feed"
|
173
|
+
title = options[:feed_title] || "OpenWFEru engine activity feed"
|
174
|
+
|
175
|
+
feed = Atom::Feed.new feed_uri
|
176
|
+
feed.title = title
|
177
|
+
|
178
|
+
format = options[:format]
|
179
|
+
format = validate_format(format)
|
180
|
+
|
181
|
+
@entries.each do |e|
|
182
|
+
|
183
|
+
next unless participant_regex.match(e.participant_name)
|
184
|
+
next if upon and upon != e.upon
|
185
|
+
|
186
|
+
feed.updated = e.updated \
|
187
|
+
if feed.updated == nil or e.updated > feed.updated
|
188
|
+
|
189
|
+
feed << as_atom_entry(format, e)
|
190
|
+
end
|
191
|
+
|
192
|
+
feed
|
193
|
+
end
|
194
|
+
|
195
|
+
protected
|
196
|
+
|
197
|
+
#
|
198
|
+
# Makes sure the required 'render' is valid. Returns it
|
199
|
+
# as a Symbol.
|
200
|
+
#
|
201
|
+
def validate_format (f)
|
202
|
+
|
203
|
+
f = "as_#{f}"
|
204
|
+
return :as_yaml unless methods.include?(f)
|
205
|
+
f.to_sym
|
206
|
+
end
|
207
|
+
|
208
|
+
def as_atom_entry (render, entry)
|
209
|
+
|
210
|
+
send(
|
211
|
+
render,
|
212
|
+
entry.as_atom_entry,
|
213
|
+
entry.participant_name,
|
214
|
+
entry.upon,
|
215
|
+
entry.workitem)
|
216
|
+
end
|
217
|
+
|
218
|
+
#
|
219
|
+
# A basic rendition of a workitem as a YAML string
|
220
|
+
#
|
221
|
+
def as_yaml (atom_entry, participant_name, upon, workitem)
|
222
|
+
|
223
|
+
atom_entry.title = "#{participant_name} - #{upon}"
|
224
|
+
atom_entry.content = workitem.to_yaml
|
225
|
+
atom_entry.content['type'] = "text/plain"
|
226
|
+
|
227
|
+
atom_entry
|
228
|
+
end
|
229
|
+
|
230
|
+
#class Atom::Content
|
231
|
+
# def convert_contents e
|
232
|
+
# REXML::CData.new(@content.to_s)
|
233
|
+
# end
|
234
|
+
#end
|
235
|
+
|
236
|
+
#
|
237
|
+
# A basic rendition of a workitem as text.
|
238
|
+
#
|
239
|
+
def as_text (atom_entry, participant_name, upon, workitem)
|
240
|
+
|
241
|
+
atom_entry.title = "#{participant_name} - #{upon}"
|
242
|
+
|
243
|
+
atom_entry.content = workitem.to_s
|
244
|
+
atom_entry.content['type'] = "text/plain"
|
245
|
+
|
246
|
+
atom_entry
|
247
|
+
end
|
248
|
+
|
249
|
+
#
|
250
|
+
# Renders the workitem as a JSON string.
|
251
|
+
#
|
252
|
+
def as_json (atom_entry, participant_name, upon, workitem)
|
253
|
+
|
254
|
+
atom_entry.title = "#{participant_name} - #{upon}"
|
255
|
+
|
256
|
+
atom_entry.content = workitem.to_json
|
257
|
+
atom_entry.content['type'] = "text/plain"
|
258
|
+
|
259
|
+
atom_entry
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
end
|
264
|
+
|
@@ -0,0 +1,485 @@
|
|
1
|
+
|
2
|
+
#
|
3
|
+
# This code was written by 37signals.com
|
4
|
+
#
|
5
|
+
# The original is at :
|
6
|
+
# http://developer.37signals.com/basecamp/basecamp.rb
|
7
|
+
#
|
8
|
+
|
9
|
+
require 'net/https'
|
10
|
+
require 'yaml'
|
11
|
+
require 'date'
|
12
|
+
require 'time'
|
13
|
+
|
14
|
+
#require 'rubygems'
|
15
|
+
gem 'xml-simple'; require 'xmlsimple'
|
16
|
+
|
17
|
+
# An interface to the Basecamp web-services API. Usage is straightforward:
|
18
|
+
#
|
19
|
+
# session = Basecamp.new('your.basecamp.com', 'username', 'password')
|
20
|
+
# puts "projects: #{session.projects.length}"
|
21
|
+
class Basecamp #:nodoc:
|
22
|
+
|
23
|
+
# A wrapper to encapsulate the data returned by Basecamp, for easier access.
|
24
|
+
class Record #:nodoc:
|
25
|
+
attr_reader :type
|
26
|
+
|
27
|
+
def initialize(type, hash)
|
28
|
+
@type = type
|
29
|
+
@hash = hash
|
30
|
+
end
|
31
|
+
|
32
|
+
def [](name)
|
33
|
+
name = dashify(name)
|
34
|
+
case @hash[name]
|
35
|
+
when Hash then
|
36
|
+
@hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
|
37
|
+
@hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
|
38
|
+
Record.new(name, @hash[name])
|
39
|
+
else @hash[name]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def id
|
44
|
+
@hash["id"]
|
45
|
+
end
|
46
|
+
|
47
|
+
def attributes
|
48
|
+
@hash.keys
|
49
|
+
end
|
50
|
+
|
51
|
+
def respond_to?(sym)
|
52
|
+
super || @hash.has_key?(dashify(sym))
|
53
|
+
end
|
54
|
+
|
55
|
+
def method_missing(sym, *args)
|
56
|
+
if args.empty? && !block_given? && respond_to?(sym)
|
57
|
+
self[sym]
|
58
|
+
else
|
59
|
+
super
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def to_s
|
64
|
+
"\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
|
65
|
+
end
|
66
|
+
|
67
|
+
def inspect
|
68
|
+
to_s
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def dashify(name)
|
74
|
+
name.to_s.tr("_", "-")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# A wrapper to represent a file that should be uploaded. This is used so that
|
79
|
+
# the form/multi-part encoder knows when to encode a field as a file, versus
|
80
|
+
# when to encode it as a simple field.
|
81
|
+
class FileUpload #:nodoc:
|
82
|
+
attr_reader :filename, :content
|
83
|
+
|
84
|
+
def initialize(filename, content)
|
85
|
+
@filename = filename
|
86
|
+
@content = content
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
attr_accessor :use_xml
|
91
|
+
|
92
|
+
# Connects
|
93
|
+
def initialize(url, user_name, password, use_ssl = false)
|
94
|
+
@use_xml = false
|
95
|
+
@user_name, @password = user_name, password
|
96
|
+
connect!(url, use_ssl)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Return the list of all accessible projects.
|
100
|
+
def projects
|
101
|
+
records "project", "/project/list"
|
102
|
+
end
|
103
|
+
|
104
|
+
# Returns the list of message categories for the given project
|
105
|
+
def message_categories(project_id)
|
106
|
+
records "post-category", "/projects/#{project_id}/post_categories"
|
107
|
+
end
|
108
|
+
|
109
|
+
# Returns the list of file categories for the given project
|
110
|
+
def file_categories(project_id)
|
111
|
+
records "attachment-category", "/projects/#{project_id}/attachment_categories"
|
112
|
+
end
|
113
|
+
|
114
|
+
# Return information for the company with the given id
|
115
|
+
def company(id)
|
116
|
+
record "/contacts/company/#{id}"
|
117
|
+
end
|
118
|
+
|
119
|
+
# Return an array of the people in the given company. If the project-id is
|
120
|
+
# given, only people who have access to the given project will be returned.
|
121
|
+
def people(company_id, project_id=nil)
|
122
|
+
url = project_id ? "/projects/#{project_id}" : ""
|
123
|
+
url << "/contacts/people/#{company_id}"
|
124
|
+
records "person", url
|
125
|
+
end
|
126
|
+
|
127
|
+
# Return information about the person with the given id
|
128
|
+
def person(id)
|
129
|
+
record "/contacts/person/#{id}"
|
130
|
+
end
|
131
|
+
|
132
|
+
# Return information about the message(s) with the given id(s). The API
|
133
|
+
# limits you to requesting 25 messages at a time, so if you need to get more
|
134
|
+
# than that, you'll need to do it in multiple requests.
|
135
|
+
def message(*ids)
|
136
|
+
result = records("post", "/msg/get/#{ids.join(",")}")
|
137
|
+
result.length == 1 ? result.first : result
|
138
|
+
end
|
139
|
+
|
140
|
+
# Returns a summary of all messages in the given project (and category, if
|
141
|
+
# specified). The summary is simply the title and category of the message,
|
142
|
+
# as well as the number of attachments (if any).
|
143
|
+
def message_list(project_id, category_id=nil)
|
144
|
+
url = "/projects/#{project_id}/msg"
|
145
|
+
url << "/cat/#{category_id}" if category_id
|
146
|
+
url << "/archive"
|
147
|
+
|
148
|
+
records "post", url
|
149
|
+
end
|
150
|
+
|
151
|
+
# Create a new message in the given project. The +message+ parameter should
|
152
|
+
# be a hash. The +email_to+ parameter must be an array of person-id's that
|
153
|
+
# should be notified of the post.
|
154
|
+
#
|
155
|
+
# If you want to add attachments to the message, the +attachments+ parameter
|
156
|
+
# should be an array of hashes, where each has has a :name key (optional),
|
157
|
+
# and a :file key (required). The :file key must refer to a Basecamp::FileUpload
|
158
|
+
# instance.
|
159
|
+
#
|
160
|
+
# msg = session.post_message(158141,
|
161
|
+
# { :title => "Requirements",
|
162
|
+
# :body => "Here are the requirements documents you asked for.",
|
163
|
+
# :category_id => 2301121 },
|
164
|
+
# [john.id, martha.id],
|
165
|
+
# [ { :name => "Primary Requirements",
|
166
|
+
# :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
|
167
|
+
# { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
|
168
|
+
def post_message(project_id, message, notify=[], attachments=[])
|
169
|
+
prepare_attachments(attachments)
|
170
|
+
record "/projects/#{project_id}/msg/create",
|
171
|
+
:post => message,
|
172
|
+
:notify => notify,
|
173
|
+
:attachments => attachments
|
174
|
+
end
|
175
|
+
|
176
|
+
# Edit the message with the given id. The +message+ parameter should
|
177
|
+
# be a hash. The +email_to+ parameter must be an array of person-id's that
|
178
|
+
# should be notified of the post.
|
179
|
+
#
|
180
|
+
# The +attachments+ parameter, if used, should be the same as described for
|
181
|
+
# #post_message.
|
182
|
+
def update_message(id, message, notify=[], attachments=[])
|
183
|
+
prepare_attachments(attachments)
|
184
|
+
record "/msg/update/#{id}",
|
185
|
+
:post => message,
|
186
|
+
:notify => notify,
|
187
|
+
:attachments => attachments
|
188
|
+
end
|
189
|
+
|
190
|
+
# Deletes the message with the given id, and returns it.
|
191
|
+
def delete_message(id)
|
192
|
+
record "/msg/delete/#{id}"
|
193
|
+
end
|
194
|
+
|
195
|
+
# Return a list of the comments for the specified message.
|
196
|
+
def comments(post_id)
|
197
|
+
records "comment", "/msg/comments/#{post_id}"
|
198
|
+
end
|
199
|
+
|
200
|
+
# Retrieve a specific comment
|
201
|
+
def comment(id)
|
202
|
+
record "/msg/comment/#{id}"
|
203
|
+
end
|
204
|
+
|
205
|
+
# Add a new comment to a message. +comment+ must be a hash describing the
|
206
|
+
# comment. You can add attachments to the comment, too, by giving them in
|
207
|
+
# an array. See the #post_message method for a description of how to do that.
|
208
|
+
def create_comment(post_id, comment, attachments=[])
|
209
|
+
prepare_attachments(attachments)
|
210
|
+
record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
|
211
|
+
:attachments => attachments
|
212
|
+
end
|
213
|
+
|
214
|
+
# Update the given comment. Attachments follow the same format as #post_message.
|
215
|
+
def update_comment(id, comment, attachments=[])
|
216
|
+
prepare_attachments(attachments)
|
217
|
+
record "/msg/update_comment", :comment_id => id,
|
218
|
+
:comment => comment, :attachments => attachments
|
219
|
+
end
|
220
|
+
|
221
|
+
# Deletes (and returns) the given comment.
|
222
|
+
def delete_comment(id)
|
223
|
+
record "/msg/delete_comment/#{id}"
|
224
|
+
end
|
225
|
+
|
226
|
+
# =========================================================================
|
227
|
+
# TODO LISTS AND ITEMS
|
228
|
+
# =========================================================================
|
229
|
+
|
230
|
+
# Marks the given item completed.
|
231
|
+
def complete_item(id)
|
232
|
+
record "/todos/complete_item/#{id}"
|
233
|
+
end
|
234
|
+
|
235
|
+
# Marks the given item uncompleted.
|
236
|
+
def uncomplete_item(id)
|
237
|
+
record "/todos/uncomplete_item/#{id}"
|
238
|
+
end
|
239
|
+
|
240
|
+
# Creates a new to-do item.
|
241
|
+
def create_item(list_id, content, responsible_party=nil, notify=true)
|
242
|
+
record "/todos/create_item/#{list_id}",
|
243
|
+
:content => content, :responsible_party => responsible_party,
|
244
|
+
:notify => notify
|
245
|
+
end
|
246
|
+
|
247
|
+
# Creates a new list using the given hash of list metadata.
|
248
|
+
def create_list(project_id, list)
|
249
|
+
record "/projects/#{project_id}/todos/create_list", list
|
250
|
+
end
|
251
|
+
|
252
|
+
# Deletes the given item from it's parent list.
|
253
|
+
def delete_item(id)
|
254
|
+
record "/todos/delete_item/#{id}"
|
255
|
+
end
|
256
|
+
|
257
|
+
# Deletes the given list and all of its items.
|
258
|
+
def delete_list(id)
|
259
|
+
record "/todos/delete_list/#{id}"
|
260
|
+
end
|
261
|
+
|
262
|
+
# Retrieves the specified list, and all of its items.
|
263
|
+
def get_list(id)
|
264
|
+
record "/todos/list/#{id}"
|
265
|
+
end
|
266
|
+
|
267
|
+
# Return all lists for a project. If complete is true, only completed lists
|
268
|
+
# are returned. If complete is false, only uncompleted lists are returned.
|
269
|
+
def lists(project_id, complete=nil)
|
270
|
+
records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
|
271
|
+
end
|
272
|
+
|
273
|
+
# Repositions an item to be at the given position in its list
|
274
|
+
def move_item(id, to)
|
275
|
+
record "/todos/move_item/#{id}", :to => to
|
276
|
+
end
|
277
|
+
|
278
|
+
# Repositions a list to be at the given position in its project
|
279
|
+
def move_list(id, to)
|
280
|
+
record "/todos/move_list/#{id}", :to => to
|
281
|
+
end
|
282
|
+
|
283
|
+
# Updates the given item
|
284
|
+
def update_item(id, content, responsible_party=nil, notify=true)
|
285
|
+
record "/todos/update_item/#{id}",
|
286
|
+
:item => { :content => content }, :responsible_party => responsible_party,
|
287
|
+
:notify => notify
|
288
|
+
end
|
289
|
+
|
290
|
+
# Updates the given list's metadata
|
291
|
+
def update_list(id, list)
|
292
|
+
record "/todos/update_list/#{id}", :list => list
|
293
|
+
end
|
294
|
+
|
295
|
+
# =========================================================================
|
296
|
+
# MILESTONES
|
297
|
+
# =========================================================================
|
298
|
+
|
299
|
+
# Complete the milestone with the given id
|
300
|
+
def complete_milestone(id)
|
301
|
+
record "/milestones/complete/#{id}"
|
302
|
+
end
|
303
|
+
|
304
|
+
# Create a new milestone for the given project. +data+ must be hash of the
|
305
|
+
# values to set, including +title+, +deadline+, +responsible_party+, and
|
306
|
+
# +notify+.
|
307
|
+
def create_milestone(project_id, data)
|
308
|
+
create_milestones(project_id, [data]).first
|
309
|
+
end
|
310
|
+
|
311
|
+
# As #create_milestone, but can create multiple milestones in a single
|
312
|
+
# request. The +milestones+ parameter must be an array of milestone values as
|
313
|
+
# descrbed in #create_milestone.
|
314
|
+
def create_milestones(project_id, milestones)
|
315
|
+
records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
|
316
|
+
end
|
317
|
+
|
318
|
+
# Destroys the milestone with the given id.
|
319
|
+
def delete_milestone(id)
|
320
|
+
record "/milestones/delete/#{id}"
|
321
|
+
end
|
322
|
+
|
323
|
+
# Returns a list of all milestones for the given project, optionally filtered
|
324
|
+
# by whether they are completed, late, or upcoming.
|
325
|
+
def milestones(project_id, find="all")
|
326
|
+
records "milestone", "/projects/#{project_id}/milestones/list", :find => find
|
327
|
+
end
|
328
|
+
|
329
|
+
# Uncomplete the milestone with the given id
|
330
|
+
def uncomplete_milestone(id)
|
331
|
+
record "/milestones/uncomplete/#{id}"
|
332
|
+
end
|
333
|
+
|
334
|
+
# Updates an existing milestone.
|
335
|
+
def update_milestone(id, data, move=false, move_off_weekends=false)
|
336
|
+
record "/milestones/update/#{id}", :milestone => data,
|
337
|
+
:move_upcoming_milestones => move,
|
338
|
+
:move_upcoming_milestones_off_weekends => move_off_weekends
|
339
|
+
end
|
340
|
+
|
341
|
+
# Make a raw web-service request to Basecamp. This will return a Hash of
|
342
|
+
# Arrays of the response, and may seem a little odd to the uninitiated.
|
343
|
+
def request(path, parameters = {}, second_try = false)
|
344
|
+
response = post(path, convert_body(parameters), "Content-Type" => content_type)
|
345
|
+
|
346
|
+
if response.code.to_i / 100 == 2
|
347
|
+
result = XmlSimple.xml_in(response.body, 'keeproot' => true,
|
348
|
+
'contentkey' => '__content__', 'forcecontent' => true)
|
349
|
+
typecast_value(result)
|
350
|
+
elsif response.code == "302" && !second_try
|
351
|
+
connect!(@url, !@use_ssl)
|
352
|
+
request(path, parameters, true)
|
353
|
+
else
|
354
|
+
raise "#{response.message} (#{response.code})"
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
# A convenience method for wrapping the result of a query in a Record
|
359
|
+
# object. This assumes that the result is a singleton, not a collection.
|
360
|
+
def record(path, parameters={})
|
361
|
+
result = request(path, parameters)
|
362
|
+
(result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
|
363
|
+
end
|
364
|
+
|
365
|
+
# A convenience method for wrapping the result of a query in Record
|
366
|
+
# objects. This assumes that the result is a collection--any singleton
|
367
|
+
# result will be wrapped in an array.
|
368
|
+
def records(node, path, parameters={})
|
369
|
+
result = request(path, parameters).values.first or return []
|
370
|
+
result = result[node] or return []
|
371
|
+
result = [result] unless Array === result
|
372
|
+
result.map { |row| Record.new(node, row) }
|
373
|
+
end
|
374
|
+
|
375
|
+
private
|
376
|
+
|
377
|
+
def connect!(url, use_ssl)
|
378
|
+
@use_ssl = use_ssl
|
379
|
+
@url = url
|
380
|
+
@connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
|
381
|
+
@connection.use_ssl = @use_ssl
|
382
|
+
@connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
|
383
|
+
end
|
384
|
+
|
385
|
+
def convert_body(body)
|
386
|
+
body = use_xml ? body.to_xml : body.to_yaml
|
387
|
+
end
|
388
|
+
|
389
|
+
def content_type
|
390
|
+
use_xml ? "application/xml" : "application/x-yaml"
|
391
|
+
end
|
392
|
+
|
393
|
+
def post(path, body, header={})
|
394
|
+
request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
|
395
|
+
request.basic_auth(@user_name, @password)
|
396
|
+
@connection.request(request, body)
|
397
|
+
end
|
398
|
+
|
399
|
+
def store_file(contents)
|
400
|
+
response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
|
401
|
+
'Accept' => 'application/xml')
|
402
|
+
|
403
|
+
if response.code == "200"
|
404
|
+
result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
|
405
|
+
return result["upload"]["id"]
|
406
|
+
else
|
407
|
+
raise "Could not store file: #{response.message} (#{response.code})"
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
def typecast_value(value)
|
412
|
+
case value
|
413
|
+
when Hash
|
414
|
+
if value.has_key?("__content__")
|
415
|
+
content = translate_entities(value["__content__"]).strip
|
416
|
+
case value["type"]
|
417
|
+
when "integer" then content.to_i
|
418
|
+
when "boolean" then content == "true"
|
419
|
+
when "datetime" then Time.parse(content)
|
420
|
+
when "date" then Date.parse(content)
|
421
|
+
else content
|
422
|
+
end
|
423
|
+
# a special case to work-around a bug in XmlSimple. When you have an empty
|
424
|
+
# tag that has an attribute, XmlSimple will not add the __content__ key
|
425
|
+
# to the returned hash. Thus, we check for the presense of the 'type'
|
426
|
+
# attribute to look for empty, typed tags, and simply return nil for
|
427
|
+
# their value.
|
428
|
+
elsif value.keys == %w(type)
|
429
|
+
nil
|
430
|
+
elsif value["nil"] == "true"
|
431
|
+
nil
|
432
|
+
# another special case, introduced by the latest rails, where an array
|
433
|
+
# type now exists. This is parsed by XmlSimple as a two-key hash, where
|
434
|
+
# one key is 'type' and the other is the actual array value.
|
435
|
+
elsif value.keys.length == 2 && value["type"] == "array"
|
436
|
+
value.delete("type")
|
437
|
+
typecast_value(value)
|
438
|
+
else
|
439
|
+
value.empty? ? nil : value.inject({}) do |h,(k,v)|
|
440
|
+
h[k] = typecast_value(v)
|
441
|
+
h
|
442
|
+
end
|
443
|
+
end
|
444
|
+
when Array
|
445
|
+
value.map! { |i| typecast_value(i) }
|
446
|
+
case value.length
|
447
|
+
when 0 then nil
|
448
|
+
when 1 then value.first
|
449
|
+
else value
|
450
|
+
end
|
451
|
+
else
|
452
|
+
raise "can't typecast #{value.inspect}"
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
def translate_entities(value)
|
457
|
+
value.gsub(/</, "<").
|
458
|
+
gsub(/>/, ">").
|
459
|
+
gsub(/"/, '"').
|
460
|
+
gsub(/'/, "'").
|
461
|
+
gsub(/&/, "&")
|
462
|
+
end
|
463
|
+
|
464
|
+
def prepare_attachments(list)
|
465
|
+
(list || []).each do |data|
|
466
|
+
upload = data[:file]
|
467
|
+
id = store_file(upload.content)
|
468
|
+
data[:file] = { :file => id,
|
469
|
+
:content_type => "application/octet-stream",
|
470
|
+
:original_filename => upload.filename }
|
471
|
+
end
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
# A minor hack to let Xml-Simple serialize symbolic keys in hashes
|
476
|
+
#class Symbol
|
477
|
+
# def [](*args)
|
478
|
+
# to_s[*args]
|
479
|
+
# end
|
480
|
+
#end
|
481
|
+
#class Hash
|
482
|
+
# def to_xml
|
483
|
+
# XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
|
484
|
+
# end
|
485
|
+
#end
|