seven1m-onebody-updateagent 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/README.markdown ADDED
@@ -0,0 +1,77 @@
1
+ OneBody UpdateAgent
2
+ ===================
3
+
4
+ Ruby gem that pushes data from a membership data source to a remote OneBody instance via the REST API.
5
+
6
+ Download and Install
7
+ --------------------
8
+
9
+ Run the following command ("sudo" may be required in some environments):
10
+
11
+ gem install seven1m-onebody-updateagent -s http://gems.github.com
12
+
13
+ Configuration
14
+ -------------
15
+
16
+ 1. Run `update_onebody` at a terminal, then take note of where the example.yml config file resides.
17
+ 2. Copy the example config file to a convenient location and edit appropriately.
18
+
19
+ Your "site" address will probably be something like "http://example.com" or
20
+ "http://yoursite.beonebody.com".
21
+
22
+ You can get your user api key from OneBody (you must be a super user) by running the following
23
+ command (on the server):
24
+
25
+ cd /path/to/onebody
26
+ rake onebody:api:key EMAIL=admin@example.com
27
+
28
+ If your instance is hosted at <http://beonebody.com>, you may email <support@beonebody.com>.
29
+
30
+ Preparation
31
+ -----------
32
+
33
+ Using your membership management software, reporting solution, database utility, custom script, etc.,
34
+ export your people and family data to a single comma separated values (CSV) file, e.g. people.csv.
35
+
36
+ Duplicate family data should be present for each member of the same family.
37
+
38
+ The first row of the file is the attribute headings, and must match the attributes available:
39
+ * http://github.com/seven1m/onebody/tree/master/app/models/person.rb
40
+ * http://github.com/seven1m/onebody/tree/master/app/models/family.rb (prefix each with "family_")
41
+
42
+ Not all attributes are required.
43
+
44
+ Optionally, if you have shell access to your hosted instance of OneBody, you can run
45
+ `rake onebody:export:people:csv` to export the current OneBody data (if you have any records in
46
+ the OneBody database) as a starting point.
47
+
48
+ Use the following attributes to track the identity/foreign keys from your existing membership
49
+ management database. Do *not* include "id" and "family_id" columns.
50
+ * legacy\_id
51
+ * legacy\_family\_id
52
+
53
+ Converters
54
+ ----------
55
+
56
+ As of this writing, one Church Management System (ChMS) is supported via a Converter. The
57
+ Converter translates field names and data into the formats and locations expected by OneBody.
58
+
59
+ To use a converter, you must specify it in your config file. The example config file has these
60
+ settings disabled; simply remove the pound sign at the beginning of each line to enable the
61
+ use of the converter. Specify the name and any additional settings.
62
+
63
+ Usage
64
+ -----
65
+
66
+ To run UpdateAgent:
67
+
68
+ update_onebody -c path/to/config.yml path/to/people.yml
69
+
70
+ If you plan to scheduled UpdateAgent to run periodically without human intervention,
71
+ you'll want to at least use the `-y` switch, which assumes *yes* to any questions:
72
+
73
+ update_onebody -c path/to/config.yml -y path/to/people.yml
74
+
75
+ You may also log all output:
76
+
77
+ update_onebody -c path/to/config.yml -y -l path/to/updateagent.log path/to/people.yml
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'yaml'
5
+
6
+ options = {:confirm => true, :force => false}
7
+ opt_parser = OptionParser.new do |opts|
8
+ opts.banner = "Usage: ruby updateagent.rb -c path/to/config.yml [options] path/to/people.csv"
9
+ opts.on("-y", "--no-confirm", "Assume 'yes' to any questions") do |v|
10
+ options[:confirm] = false
11
+ end
12
+ opts.on("-l", "--log LOGFILE", "Output to log rather than stdout") do |log|
13
+ $stdout = $stderr = File.open(log, 'a')
14
+ end
15
+ opts.on("-f", "--force", "Force update all records") do |f|
16
+ options[:force] = true
17
+ end
18
+ opts.on("-c", "--config-file PATH", "Path to configuration file") do |c|
19
+ options[:config_file] = c
20
+ config = YAML::load(File.read(c))
21
+ SITE = config['site']
22
+ USER_EMAIL = config['user_email']
23
+ USER_KEY = config['user_key']
24
+ if c = config['converter']
25
+ USE_CONVERTER = c['name']
26
+ CONVERTER_CONFIG = c
27
+ end
28
+ end
29
+ end
30
+ opt_parser.parse!
31
+
32
+ unless options[:config_file]
33
+ puts opt_parser.help
34
+ puts
35
+ puts 'You must specify a config file containing site and login info.'
36
+ puts "See #{File.expand_path(File.dirname(File.dirname(__FILE__)))}/example.yml"
37
+ puts
38
+ exit
39
+ end
40
+
41
+ if ARGV[0] # path/to/people.csv
42
+ require 'onebody-updateagent'
43
+ puts "Update Agent running at #{Time.now.strftime('%m/%d/%Y %I:%M %p')}"
44
+ agent = PeopleUpdater.new(ARGV[0])
45
+ puts "comparing records..."
46
+ agent.compare(options[:force])
47
+ if agent.has_work?
48
+ if options[:confirm]
49
+ case ask("#{agent.create.length + agent.update.length} record(s) to push. Continue? (Yes, No, Review) ") { |q| q.in = %w(yes no review y n r) }
50
+ when 'review', 'r'
51
+ agent.present
52
+ unless agent.confirm
53
+ puts "Canceled by user\n"
54
+ exit
55
+ end
56
+ when 'no', 'n'
57
+ puts "Canceled by user\n"
58
+ exit
59
+ end
60
+ end
61
+ agent.push
62
+ puts "Completed at #{Time.now.strftime('%m/%d/%Y %I:%M %p')}\n\n"
63
+ else
64
+ puts "Nothing to push\n\n"
65
+ end
66
+ else
67
+ puts opt_parser.help
68
+ end
data/example.yml ADDED
@@ -0,0 +1,20 @@
1
+ site: http://localhost:3000
2
+ user_email: admin@example.com
3
+ user_key: dafH2KIiAcnLEr5JxjmX2oveuczq0R6u7Ijd329DtjatgdYcKp
4
+ #converter:
5
+ # name: ACS
6
+ # visible:
7
+ # - Member
8
+ # - Visitor
9
+ # - Prospect
10
+ # - Attender
11
+ # - College
12
+ # - Other Child
13
+ # - Other Youth
14
+ # visible_on_printed_directory:
15
+ # - Member
16
+ # - Attender
17
+ # full_access:
18
+ # - Member
19
+ # - Attender
20
+ # - College
@@ -0,0 +1,20 @@
1
+ site: http://localhost:3000
2
+ user_email: admin@example.com
3
+ user_key: dafH2KIiAcnLEr5JxjmX2oveuczq0R6u7Ijd329DtjatgdYcKp
4
+ converter:
5
+ name: ACS
6
+ visible:
7
+ - Member
8
+ - Visitor
9
+ - Prospect
10
+ - Attender
11
+ - College
12
+ - Other Child
13
+ - Other Youth
14
+ visible_on_printed_directory:
15
+ - Member
16
+ - Attender
17
+ full_access:
18
+ - Member
19
+ - Attender
20
+ - College
@@ -0,0 +1,19 @@
1
+ require 'date'
2
+ require 'rubygems'
3
+ require 'fastercsv'
4
+ require 'highline/import'
5
+ require 'activeresource'
6
+ require 'digest/sha1'
7
+
8
+ HighLine.track_eof = false
9
+
10
+ require 'updateagent/resources'
11
+ require 'updateagent/schema'
12
+ require 'updateagent/hash_extensions'
13
+ require 'updateagent/updateagent'
14
+ require 'updateagent/updaters/people_updater'
15
+ require 'updateagent/updaters/family_updater'
16
+
17
+ if defined?(USE_CONVERTER)
18
+ require "updateagent/converters/#{USE_CONVERTER.downcase}_converter"
19
+ end
@@ -0,0 +1,84 @@
1
+ class ACSConverter
2
+
3
+ def straight
4
+ {
5
+ "FamilyNumber" => "legacy_family_id",
6
+ "IndividualNumber" => "sequence",
7
+ "LastName" => "last_name",
8
+ "Suffix" => "suffix",
9
+ "Address1" => "family_address1",
10
+ "Address2" => "family_address2",
11
+ "City" => "family_city",
12
+ "State" => "family_state",
13
+ "ZIPCode" => "family_zip",
14
+ "DateOfBirth" => "birthday",
15
+ "HomeEmailAddr" => "email",
16
+ "FamilyName" => "family_name",
17
+ }
18
+ end
19
+
20
+ def convert(records)
21
+ create_family_names(records)
22
+ records.map do |record|
23
+ convert_record(record)
24
+ end
25
+ end
26
+
27
+ def convert_record(record)
28
+ returning({}) do |new_record|
29
+ record.each do |key, value|
30
+ if new_key = straight[key]
31
+ new_record[new_key] = value
32
+ end
33
+ end
34
+ new_record['legacy_id'] = new_record['legacy_family_id'] + new_record['sequence']
35
+ new_record['first_name'] = get_first_name(record)
36
+ new_record['gender'] = record['FamilyPosition'] == 'Child' ? {'Male' => 'Boy', 'Female' => 'Girl'}[record['Gender']] : record['Gender']
37
+ new_record['family_home_phone'] = get_phone(record, 'Home')
38
+ new_record['work_phone'] = get_phone(record, 'Business')
39
+ new_record['mobile_phone'] = get_phone(record, 'Cell')
40
+ new_record['fax'] = get_phone(record, 'FAX')
41
+ new_record['family_last_name'] = new_record['last_name']
42
+ new_record['family_name'] = @family_names[new_record['legacy_family_id']]
43
+ new_record['visible_to_everyone'] = CONVERTER_CONFIG['visible'].include?(record['MemberStatus'])
44
+ new_record['visible_on_printed_directory'] = CONVERTER_CONFIG['visible_on_printed_directory'].include?(record['MemberStatus'])
45
+ new_record['full_access'] = CONVERTER_CONFIG['full_access'].include?(record['MemberStatus'])
46
+ new_record['email'] = record['HomeEmailAddr'].to_s.any? ? record['HomeEmailAddr'] : record['BusinessEmailAddr']
47
+ end
48
+ end
49
+
50
+ def create_family_names(records)
51
+ # this could probably be less messy
52
+ @families = {}
53
+ records.each do |record|
54
+ @families[record['FamilyNumber']] ||= {}
55
+ @families[record['FamilyNumber']][record['FamilyPosition']] = record
56
+ end
57
+ @family_names = {}
58
+ @families.each do |family_number, family|
59
+ family['Head'] ||= family.delete('Spouse') || family.delete('Child')
60
+ if family['Head']
61
+ if family['Spouse']
62
+ @family_names[family_number] = "#{get_first_name(family['Head'])} & #{get_first_name(family['Spouse'])} #{family['Head']['LastName']}"
63
+ else
64
+ @family_names[family_number] = "#{get_first_name(family['Head'])} #{family['Head']['LastName']}"
65
+ end
66
+ else
67
+ @family_names[family_number] = "#{get_first_name(family['Other'])} #{family['Other']['LastName']}"
68
+ end
69
+ end
70
+ end
71
+
72
+ def get_first_name(record)
73
+ record['GoesByName'].to_s.any? ? record['GoesByName'] : record['FirstName']
74
+ end
75
+
76
+ def get_phone(record, type)
77
+ unless record[type + 'Unlisted'].to_s.downcase == 'true'
78
+ phone = record[type + 'Phone'].to_s.scan(/\d/).join
79
+ phone << ' ' + record[type + 'Extension'] if record[type + 'Extension'].to_s.any?
80
+ phone
81
+ end
82
+ end
83
+
84
+ end
@@ -0,0 +1,21 @@
1
+ class Hash
2
+ # creates a uniq sha1 digest of the hash's values
3
+ # should mirror similar code in OneBody's lib/db_tools.rb
4
+ def values_hash(*attrs)
5
+ attrs = keys.sort unless attrs.any?
6
+ attrs = attrs.first if attrs.first.is_a?(Array)
7
+ values = attrs.map do |attr|
8
+ value = self[attr.to_s]
9
+ if value.respond_to?(:strftime)
10
+ value.strftime('%Y-%m-%d %H:%M:%S')
11
+ elsif value == true
12
+ 1
13
+ elsif value == false
14
+ 0
15
+ else
16
+ value
17
+ end
18
+ end
19
+ DEBUG ? values.join : Digest::SHA1.hexdigest(values.join)
20
+ end
21
+ end
@@ -0,0 +1,8 @@
1
+ class Base < ActiveResource::Base
2
+ self.site = SITE
3
+ self.user = USER_EMAIL
4
+ self.password = USER_KEY
5
+ end
6
+
7
+ class Person < Base; end
8
+ class Family < Base; end
@@ -0,0 +1,15 @@
1
+ class Schema
2
+ def initialize(resource)
3
+ @schema = resource.get(:schema)
4
+ end
5
+ def type(t)
6
+ @schema.select { |c| c['type'] == t }.map { |c| c['name'] }.uniq
7
+ end
8
+ end
9
+ person_schema = Schema.new(Person)
10
+ family_schema = Schema.new(Family)
11
+
12
+ DATETIME_ATTRIBUTES = person_schema.type(:datetime) + family_schema.type(:datetime).map { |c| 'family_' + c }
13
+ BOOLEAN_ATTRIBUTES = person_schema.type(:boolean) + family_schema.type(:boolean).map { |c| 'family_' + c }
14
+ INTEGER_ATTRIBUTES = person_schema.type(:integer) + family_schema.type(:integer).map { |c| 'family_' + c }
15
+ IGNORE_ATTRIBUTES = %w(updated_at created_at family_updated_at family_latitude family_longitude)
@@ -0,0 +1,160 @@
1
+ MAX_HASHES_AT_A_TIME = 1000
2
+ MAX_TO_BATCH_AT_A_TIME = 25
3
+
4
+ DEBUG = false
5
+
6
+ # general class to handle comparing and pushing data to the remote end
7
+ class UpdateAgent
8
+ def initialize(data=nil)
9
+ @attributes = []
10
+ @data = []
11
+ @create = []
12
+ @update = []
13
+ if data
14
+ if data.is_a?(Array)
15
+ @data = data
16
+ @attributes = data.first.keys.sort
17
+ else
18
+ read_from_file(data)
19
+ end
20
+ end
21
+ if invalid = @data.detect { |row| row['id'] }
22
+ puts "Error: one or more records contain an 'id' column."
23
+ puts "You must utilize 'legacy_id' rather than 'id' so that"
24
+ puts "identity and foreign keys are maintained from your"
25
+ puts "existing membership management database."
26
+ exit
27
+ end
28
+ end
29
+
30
+ # load data from csv file and do some type conversion for bools and dates
31
+ # first row must be attribute names
32
+ def read_from_file(filename)
33
+ csv = FasterCSV.open(filename, 'r')
34
+ @attributes = csv.shift
35
+ record_count = 0
36
+ @data = csv.map do |row|
37
+ hash = {}
38
+ row.each_with_index do |value, index|
39
+ key = @attributes[index]
40
+ next if IGNORE_ATTRIBUTES.include?(key)
41
+ if DATETIME_ATTRIBUTES.include?(key)
42
+ if value.blank?
43
+ value = nil
44
+ else
45
+ begin
46
+ value = DateTime.parse(value)
47
+ rescue ArgumentError
48
+ puts "Invalid date in #{filename} record #{index} (#{key}) - #{value}"
49
+ exit(1)
50
+ end
51
+ end
52
+ elsif BOOLEAN_ATTRIBUTES.include?(key)
53
+ if value == '' or value == nil
54
+ value = nil
55
+ elsif %w(no false 0).include?(value.downcase)
56
+ value = false
57
+ else
58
+ value = true
59
+ end
60
+ elsif INTEGER_ATTRIBUTES.include?(key)
61
+ value = value.to_s != '' ? value.scan(/\d/).join.to_i : nil
62
+ end
63
+ hash[key] = value
64
+ end
65
+ record_count += 1
66
+ print "reading record #{record_count}\r"
67
+ hash
68
+ end
69
+ puts
70
+ @attributes.reject! { |a| IGNORE_ATTRIBUTES.include?(a) }
71
+ end
72
+
73
+ def ids
74
+ @data.map { |r| r['id'] }.compact
75
+ end
76
+
77
+ def legacy_ids
78
+ @data.map { |r| r['legacy_id'] }.compact
79
+ end
80
+
81
+ def compare(force=false)
82
+ compare_hashes(legacy_ids, force)
83
+ end
84
+
85
+ def has_work?
86
+ (@create + @update).any?
87
+ end
88
+
89
+ def present
90
+ puts "The following #{resource.name.downcase} records will be pushed..."
91
+ puts 'legacy id name'
92
+ puts '---------- -------------------------------------'
93
+ @create.each { |r| present_record(r, true) }
94
+ @update.each { |r| present_record(r) }
95
+ puts
96
+ end
97
+
98
+ def present_record(row, new=false)
99
+ puts "#{row['legacy_id'].to_s.ljust(10)} #{name_for(row).to_s.ljust(40)} #{new ? '(new)' : ' '}"
100
+ if DEBUG
101
+ puts row.values_hash(@attributes)
102
+ puts row['remote_hash']
103
+ end
104
+ end
105
+
106
+ def confirm
107
+ agree('Do you want to continue, pushing these records to OneBody? ')
108
+ end
109
+
110
+ # use ActiveResource to create/update records on remote end
111
+ def push
112
+ puts 'Updating remote end...'
113
+ index = 0
114
+ print "#{resource.name} 0/0\r"; STDOUT.flush
115
+ (@create + @update).each_slice(MAX_TO_BATCH_AT_A_TIME) do |records|
116
+ response = resource.post(:batch, {}, records.to_xml)
117
+ statuses = Hash.from_xml(response.body)['records']
118
+ statuses.select { |s| s['status'] == 'error' }.each do |status|
119
+ puts "#{status['legacy_id']}: #{status['error']}"
120
+ end
121
+ index += records.length
122
+ print "#{resource.name} #{index}/#{@create.length + @update.length}\r"; STDOUT.flush
123
+ end
124
+ puts
125
+ end
126
+
127
+ def data_by_id
128
+ @data_by_id ||= begin
129
+ by_id = {}
130
+ @data.each { |r| by_id[r['legacy_id'].to_i] = r }
131
+ by_id
132
+ end
133
+ end
134
+
135
+ attr_accessor :attributes, :data
136
+ attr_reader :update, :create
137
+
138
+ class << self; attr_accessor :resource; end
139
+ def resource; self.class.resource; end
140
+
141
+ protected
142
+
143
+ # ask remote end for value hashe for each record (50 at a time)
144
+ # mark records to create or update based on response
145
+ def compare_hashes(ids, force=false)
146
+ ids.each_slice(MAX_HASHES_AT_A_TIME) do |some_ids|
147
+ print '.'; STDOUT.flush
148
+ options = {:attrs => @attributes.join(','), :legacy_id => some_ids.join(',')}
149
+ options.merge!(:debug => true) if DEBUG
150
+ hashes = resource.get(:hashify, options)
151
+ hashes.each do |record|
152
+ row = data_by_id[record['legacy_id'].to_i]
153
+ row['remote_hash'] = record['hash'] if DEBUG
154
+ @update << row if force or row.values_hash(@attributes) != record['hash']
155
+ end
156
+ @create += some_ids.reject { |id| hashes.map { |h| h['legacy_id'].to_i }.include?(id.to_i) }.map { |id| data_by_id[id.to_i] }
157
+ end
158
+ puts
159
+ end
160
+ end
@@ -0,0 +1,6 @@
1
+ class FamilyUpdater < UpdateAgent
2
+ self.resource = Family
3
+ def name_for(row)
4
+ row['name']
5
+ end
6
+ end
@@ -0,0 +1,72 @@
1
+ # handles people.csv and splits out family data for FamilyUpdater
2
+ class PeopleUpdater < UpdateAgent
3
+ self.resource = Person
4
+
5
+ # split out family data and create a new FamilyUpdater
6
+ def initialize(filename)
7
+ super(filename)
8
+ if defined?(USE_CONVERTER)
9
+ converter = Kernel.const_get(USE_CONVERTER + 'Converter').new
10
+ @data = converter.convert(@data.clone)
11
+ @attributes = @data.first.keys
12
+ end
13
+ person_data = []
14
+ family_data = {}
15
+ @data.each_with_index do |row, index|
16
+ person, family = split_change_hash(row)
17
+ if existing_family = family_data[family['legacy_id']]
18
+ person['family'] = existing_family
19
+ person_data << person
20
+ else
21
+ person['family'] = family
22
+ person_data << person
23
+ family_data[family['legacy_id']] = family
24
+ end
25
+ print "splitting family record #{index+1}\r"
26
+ end
27
+ puts
28
+ @data = person_data
29
+ @attributes.reject! { |a| a =~ /^family_/ and a != 'family_id' }
30
+ @family_agent = FamilyUpdater.new(family_data.values)
31
+ end
32
+
33
+ def name_for(row)
34
+ "#{row['first_name']} #{row['last_name']}"
35
+ end
36
+
37
+ def compare(force=false)
38
+ @family_agent.compare(force)
39
+ super(force)
40
+ end
41
+
42
+ def has_work?
43
+ @family_agent.has_work? or super
44
+ end
45
+
46
+ def present
47
+ @family_agent.present if @family_agent.has_work?
48
+ super
49
+ end
50
+
51
+ def push
52
+ @family_agent.push
53
+ super
54
+ end
55
+
56
+ protected
57
+
58
+ # split hash of values into person and family values based on keys
59
+ def split_change_hash(vals)
60
+ person_vals = {}
61
+ family_vals = {}
62
+ vals.each do |key, val|
63
+ if key =~ /^family_/
64
+ family_vals[key.sub(/^family_/, '')] = val
65
+ else
66
+ person_vals[key] = val
67
+ end
68
+ end
69
+ family_vals['legacy_id'] ||= person_vals['legacy_family_id']
70
+ [person_vals, family_vals]
71
+ end
72
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: seven1m-onebody-updateagent
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tim Morgan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-08-13 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: fastercsv
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: "0"
23
+ version:
24
+ - !ruby/object:Gem::Dependency
25
+ name: highline
26
+ version_requirement:
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: "0"
32
+ version:
33
+ - !ruby/object:Gem::Dependency
34
+ name: activeresource
35
+ version_requirement:
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: "0"
41
+ version:
42
+ description:
43
+ email: tim@timmorgan.org
44
+ executables:
45
+ - update_onebody
46
+ extensions: []
47
+
48
+ extra_rdoc_files: []
49
+
50
+ files:
51
+ - example.yml
52
+ - example_using_converter.yml
53
+ - README.markdown
54
+ - bin/update_onebody
55
+ - lib/onebody-updateagent.rb
56
+ - lib/updateagent/hash_extensions.rb
57
+ - lib/updateagent/resources.rb
58
+ - lib/updateagent/schema.rb
59
+ - lib/updateagent/updateagent.rb
60
+ - lib/updateagent/converters/acs_converter.rb
61
+ - lib/updateagent/updaters/family_updater.rb
62
+ - lib/updateagent/updaters/people_updater.rb
63
+ has_rdoc: false
64
+ homepage: http://github.com/seven1m/onebody-updateagent
65
+ post_install_message:
66
+ rdoc_options: []
67
+
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: "0"
75
+ version:
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: "0"
81
+ version:
82
+ requirements: []
83
+
84
+ rubyforge_project:
85
+ rubygems_version: 1.2.0
86
+ signing_key:
87
+ specification_version: 2
88
+ summary: Companion to OneBody that handles sync with external data source.
89
+ test_files: []
90
+