moco-ruby 0.1.2 → 1.0.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -3
- data/CHANGELOG.md +104 -5
- data/Gemfile +3 -1
- data/Gemfile.lock +72 -23
- data/README.md +225 -55
- data/examples/v2_api_example.rb +73 -0
- data/lib/moco/client.rb +47 -0
- data/lib/moco/collection_proxy.rb +200 -0
- data/lib/moco/connection.rb +68 -0
- data/lib/moco/entities/activity.rb +101 -0
- data/lib/moco/entities/base_entity.rb +312 -0
- data/lib/moco/entities/company.rb +28 -0
- data/lib/moco/entities/deal.rb +24 -0
- data/lib/moco/entities/expense.rb +37 -0
- data/lib/moco/entities/holiday.rb +25 -0
- data/lib/moco/entities/invoice.rb +53 -0
- data/lib/moco/entities/planning_entry.rb +26 -0
- data/lib/moco/entities/presence.rb +30 -0
- data/lib/moco/entities/project.rb +48 -0
- data/lib/moco/entities/schedule.rb +26 -0
- data/lib/moco/entities/task.rb +20 -0
- data/lib/moco/entities/user.rb +33 -0
- data/lib/moco/entities/web_hook.rb +27 -0
- data/lib/moco/entities.rb +11 -4
- data/lib/moco/entity_collection.rb +59 -0
- data/lib/moco/helpers.rb +1 -0
- data/lib/moco/nested_collection_proxy.rb +43 -0
- data/lib/moco/sync.rb +337 -62
- data/lib/moco/version.rb +1 -1
- data/lib/moco.rb +28 -2
- data/moco.gemspec +36 -0
- data/mocurl.rb +51 -34
- data/sync_activity.rb +12 -6
- metadata +42 -8
- data/lib/moco/api.rb +0 -194
data/lib/moco/sync.rb
CHANGED
@@ -1,105 +1,187 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "fuzzy_match"
|
4
|
-
require_relative "
|
4
|
+
require_relative "client"
|
5
5
|
|
6
6
|
module MOCO
|
7
7
|
# Match and map projects and tasks between MOCO instances and sync activities
|
8
8
|
class Sync
|
9
9
|
attr_reader :project_mapping, :task_mapping, :source_projects, :target_projects
|
10
|
-
attr_accessor :project_match_threshold, :task_match_threshold, :dry_run
|
11
|
-
|
12
|
-
def initialize(
|
13
|
-
@
|
14
|
-
@
|
10
|
+
attr_accessor :project_match_threshold, :task_match_threshold, :dry_run, :debug
|
11
|
+
|
12
|
+
def initialize(source_client, target_client, **args)
|
13
|
+
@source = source_client
|
14
|
+
@target = target_client
|
15
15
|
@project_match_threshold = args.fetch(:project_match_threshold, 0.8)
|
16
16
|
@task_match_threshold = args.fetch(:task_match_threshold, 0.45)
|
17
17
|
@filters = args.fetch(:filters, {})
|
18
18
|
@dry_run = args.fetch(:dry_run, false)
|
19
|
-
|
19
|
+
@debug = args.fetch(:debug, false)
|
20
|
+
|
20
21
|
@project_mapping = {}
|
21
22
|
@task_mapping = {}
|
22
|
-
|
23
|
+
|
23
24
|
fetch_assigned_projects
|
24
25
|
build_initial_mappings
|
25
26
|
end
|
26
|
-
|
27
|
-
# rubocop:todo Metrics/
|
27
|
+
|
28
|
+
# rubocop:todo Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
28
29
|
def sync(&callbacks)
|
29
30
|
results = []
|
30
31
|
|
31
|
-
|
32
|
-
|
32
|
+
source_activity_filters = @filters.fetch(:source, {})
|
33
|
+
source_activities_r = @source.activities.where(source_activity_filters).all
|
34
|
+
debug_log "Fetched #{source_activities_r.size} source activities"
|
35
|
+
|
36
|
+
# Log source activities for debugging
|
37
|
+
debug_log "Source activities:"
|
38
|
+
source_activities_r.each do |activity|
|
39
|
+
debug_log " Source Activity: #{activity.id}, Date: #{activity.date}, Project: #{activity.project&.id} (#{activity.project&.name}), Task: #{activity.task&.id} (#{activity.task&.name}), Hours: #{activity.hours}, Description: #{activity.description}, Remote ID: #{activity.remote_id}"
|
33
40
|
|
41
|
+
# Also log the expected target activity for each source activity
|
42
|
+
begin
|
43
|
+
expected = get_expected_target_activity(activity)
|
44
|
+
if expected
|
45
|
+
project_id = expected.project&.id rescue "N/A"
|
46
|
+
task_id = expected.task&.id rescue "N/A"
|
47
|
+
remote_id = expected.instance_variable_get(:@attributes)[:remote_id] rescue "N/A"
|
48
|
+
debug_log " Expected Target: Project: #{project_id}, Task: #{task_id}, Remote ID: #{remote_id}"
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
debug_log " Error getting expected target: #{e.message}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
target_activity_filters = @filters.fetch(:target, {})
|
56
|
+
target_activities_r = @target.activities.where(target_activity_filters).all
|
57
|
+
debug_log "Fetched #{target_activities_r.size} target activities"
|
58
|
+
|
59
|
+
# Log target activities for debugging
|
60
|
+
debug_log "Target activities:"
|
61
|
+
target_activities_r.each do |activity|
|
62
|
+
debug_log " Target Activity: #{activity.id}, Date: #{activity.date}, Project: #{activity.project&.id} (#{activity.project&.name}), Task: #{activity.task&.id} (#{activity.task&.name}), Hours: #{activity.hours}, Description: #{activity.description}, Remote ID: #{activity.remote_id}"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Group activities by date and then by project_id for consistent lookups
|
34
66
|
source_activities_grouped = source_activities_r.group_by(&:date).transform_values do |activities|
|
35
|
-
activities.group_by
|
67
|
+
activities.group_by { |a| a.project&.id } # Group by project ID
|
36
68
|
end
|
37
69
|
target_activities_grouped = target_activities_r.group_by(&:date).transform_values do |activities|
|
38
|
-
activities.group_by
|
70
|
+
activities.group_by { |a| a.project&.id } # Group by project ID
|
39
71
|
end
|
40
72
|
|
41
73
|
used_source_activities = []
|
42
74
|
used_target_activities = []
|
43
75
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
76
|
+
debug_log "Starting main sync loop..."
|
77
|
+
source_activities_grouped.each do |date, activities_by_project_id|
|
78
|
+
debug_log "Processing date: #{date}"
|
79
|
+
activities_by_project_id.each do |source_project_id, source_activities|
|
80
|
+
debug_log " Processing source project ID: #{source_project_id} (#{source_activities.count} activities)"
|
81
|
+
# Find the corresponding target project ID using the mapping
|
82
|
+
target_project_object = @project_mapping[source_project_id]
|
83
|
+
unless target_project_object
|
84
|
+
debug_log " Skipping - Source project ID #{source_project_id} not mapped."
|
85
|
+
next
|
86
|
+
end
|
87
|
+
|
88
|
+
target_project_id = target_project_object.id
|
89
|
+
# Fetch target activities using the target project ID
|
90
|
+
target_activities = target_activities_grouped.fetch(date, {}).fetch(target_project_id, [])
|
91
|
+
debug_log " Found #{target_activities.count} target activities for target project ID: #{target_project_id}"
|
92
|
+
|
93
|
+
if source_activities.empty? || target_activities.empty?
|
94
|
+
debug_log " Skipping - No source or target activities for this date/project pair."
|
95
|
+
next
|
96
|
+
end
|
48
97
|
|
49
98
|
matches = calculate_matches(source_activities, target_activities)
|
99
|
+
debug_log " Calculated #{matches.count} potential matches."
|
50
100
|
matches.sort_by! { |match| -match[:score] }
|
51
101
|
|
102
|
+
debug_log " Entering matches loop..."
|
52
103
|
matches.each do |match|
|
53
104
|
source_activity, target_activity = match[:activity]
|
54
105
|
score = match[:score]
|
106
|
+
debug_log " Match Pair: Score=#{score}, Source=#{source_activity.id}, Target=#{target_activity.id}"
|
55
107
|
|
56
|
-
|
108
|
+
if used_source_activities.include?(source_activity) || used_target_activities.include?(target_activity)
|
109
|
+
debug_log " Skipping match pair - already used: Source used=#{used_source_activities.include?(source_activity)}, Target used=#{used_target_activities.include?(target_activity)}"
|
110
|
+
next
|
111
|
+
end
|
57
112
|
|
58
|
-
best_score = score
|
113
|
+
best_score = score # Since we sorted, this is the best score for this unused pair
|
59
114
|
best_match = target_activity
|
60
115
|
expected_target_activity = get_expected_target_activity(source_activity)
|
116
|
+
debug_log " Processing best score #{best_score} for Source=#{source_activity.id}"
|
61
117
|
|
62
118
|
case best_score
|
63
119
|
when 100
|
120
|
+
debug_log " Case 100: Equal"
|
64
121
|
# 100 - perfect match found, nothing needs doing
|
65
122
|
callbacks&.call(:equal, source_activity, expected_target_activity)
|
123
|
+
# Mark both as used
|
124
|
+
debug_log " Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
|
125
|
+
used_source_activities << source_activity
|
126
|
+
used_target_activities << target_activity
|
66
127
|
when 60...100
|
128
|
+
debug_log " Case 60-99: Update"
|
67
129
|
# >=60 <100 - match with some differences
|
68
130
|
expected_target_activity.to_h.except(:id, :user, :customer).each do |k, v|
|
131
|
+
debug_log " Updating attribute #{k} on Target=#{target_activity.id}"
|
69
132
|
best_match.send("#{k}=", v)
|
70
133
|
end
|
71
134
|
callbacks&.call(:update, source_activity, best_match)
|
72
135
|
unless @dry_run
|
73
|
-
|
136
|
+
debug_log " Executing API update for Target=#{target_activity.id}"
|
137
|
+
results << @target.activities.update(best_match.id, best_match.attributes) # Pass ID and attributes
|
74
138
|
callbacks&.call(:updated, source_activity, best_match, results.last)
|
75
139
|
end
|
140
|
+
# Mark both as used
|
141
|
+
debug_log " Marking Source=#{source_activity.id} and Target=#{target_activity.id} as used."
|
142
|
+
used_source_activities << source_activity
|
143
|
+
used_target_activities << target_activity
|
76
144
|
when 0...60
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
callbacks&.call(:created, source_activity, best_match, results.last)
|
82
|
-
end
|
145
|
+
debug_log " Case 0-59: Low score, doing nothing for this pair."
|
146
|
+
# <60 - Low score for this specific pair. Do nothing here.
|
147
|
+
# Creation is handled later if source_activity remains unused.
|
148
|
+
nil # Explicitly do nothing
|
83
149
|
end
|
84
|
-
|
85
|
-
used_source_activities << source_activity
|
86
|
-
used_target_activities << target_activity
|
150
|
+
# Only mark activities as used if score >= 60 (handled within the case branches above)
|
87
151
|
end
|
152
|
+
debug_log " Finished matches loop."
|
88
153
|
end
|
154
|
+
debug_log " Finished processing project IDs for date #{date}."
|
89
155
|
end
|
156
|
+
debug_log "Finished main sync loop."
|
90
157
|
|
158
|
+
# Second loop: Create source activities that were never used (i.e., had no match >= 60)
|
159
|
+
debug_log "Starting creation loop..."
|
91
160
|
source_activities_r.each do |source_activity|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
expected_target_activity = get_expected_target_activity(source_activity)
|
96
|
-
callbacks&.call(:create, source_activity, expected_target_activity)
|
97
|
-
unless @dry_run
|
98
|
-
results << @target_api.create_activity(expected_target_activity)
|
99
|
-
callbacks&.call(:created, source_activity, expected_target_activity, results.last)
|
100
|
-
end
|
161
|
+
if used_source_activities.include?(source_activity)
|
162
|
+
debug_log " Skipping creation for Source=#{source_activity.id} - already used."
|
163
|
+
next
|
101
164
|
end
|
165
|
+
# Use safe navigation in case project is nil
|
166
|
+
source_project_id = source_activity.project&.id
|
167
|
+
unless @project_mapping[source_project_id]
|
168
|
+
debug_log " Skipping creation for Source=#{source_activity.id} - project #{source_project_id} not mapped."
|
169
|
+
next
|
170
|
+
end
|
171
|
+
|
172
|
+
debug_log " Processing creation for Source=#{source_activity.id}"
|
173
|
+
expected_target_activity = get_expected_target_activity(source_activity)
|
174
|
+
callbacks&.call(:create, source_activity, expected_target_activity)
|
175
|
+
next if @dry_run
|
176
|
+
|
177
|
+
debug_log " Executing API create."
|
178
|
+
# Pass attributes hash to create
|
179
|
+
created_activity = @target.activities.create(expected_target_activity.attributes)
|
180
|
+
results << created_activity
|
181
|
+
# Pass the actual created activity object to the callback
|
182
|
+
callbacks&.call(:created, source_activity, created_activity, results.last)
|
102
183
|
end
|
184
|
+
debug_log "Finished creation loop."
|
103
185
|
|
104
186
|
results
|
105
187
|
end
|
@@ -107,21 +189,54 @@ module MOCO
|
|
107
189
|
|
108
190
|
private
|
109
191
|
|
192
|
+
def debug_log(message)
|
193
|
+
warn "[SYNC DEBUG] #{message}" if @debug
|
194
|
+
end
|
195
|
+
|
110
196
|
def get_expected_target_activity(source_activity)
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
197
|
+
# Create a duplicate of the source activity
|
198
|
+
new_activity = source_activity.dup
|
199
|
+
|
200
|
+
# Get the attributes hash
|
201
|
+
attrs = new_activity.instance_variable_get(:@attributes)
|
202
|
+
|
203
|
+
# Store the mapped task and project objects for reference
|
204
|
+
mapped_task = @task_mapping[source_activity.task&.id]
|
205
|
+
mapped_project = @project_mapping[source_activity.project&.id]
|
206
|
+
|
207
|
+
# Set the task_id and project_id attributes instead of the full objects
|
208
|
+
attrs[:task_id] = mapped_task.id if mapped_task
|
209
|
+
attrs[:project_id] = mapped_project.id if mapped_project
|
210
|
+
|
211
|
+
# Set remote_id to the source activity ID for future matching
|
212
|
+
attrs[:remote_id] = source_activity.id.to_s
|
213
|
+
|
214
|
+
# Remove the full objects from the attributes hash
|
215
|
+
attrs.delete(:task)
|
216
|
+
attrs.delete(:project)
|
217
|
+
|
218
|
+
# Return the modified activity
|
219
|
+
new_activity
|
115
220
|
end
|
116
221
|
|
117
222
|
def calculate_matches(source_activities, target_activities)
|
118
223
|
matches = []
|
224
|
+
|
119
225
|
source_activities.each do |source_activity|
|
120
226
|
target_activities.each do |target_activity|
|
121
|
-
|
122
|
-
|
227
|
+
# First check if this is a previously synced activity by comparing IDs directly
|
228
|
+
if target_activity.respond_to?(:remote_id) &&
|
229
|
+
target_activity.remote_id.to_s == source_activity.id.to_s
|
230
|
+
debug_log "Direct match found: target.remote_id=#{target_activity.remote_id} matches source.id=#{source_activity.id}" if @debug
|
231
|
+
matches << { activity: [source_activity, target_activity], score: 100 }
|
232
|
+
else
|
233
|
+
# If no direct match, use the regular scoring method
|
234
|
+
score = score_activity_match(get_expected_target_activity(source_activity), target_activity)
|
235
|
+
matches << { activity: [source_activity, target_activity], score: }
|
236
|
+
end
|
123
237
|
end
|
124
238
|
end
|
239
|
+
|
125
240
|
matches
|
126
241
|
end
|
127
242
|
|
@@ -133,27 +248,145 @@ module MOCO
|
|
133
248
|
[0.0, score].max
|
134
249
|
end
|
135
250
|
|
136
|
-
# rubocop:disable Metrics/AbcSize
|
251
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
137
252
|
def score_activity_match(a, b)
|
253
|
+
# Must be same project
|
138
254
|
return 0 if a.project != b.project
|
139
255
|
|
256
|
+
# Check for exact ID match (for activities that were previously synced)
|
257
|
+
# This is the most important check and overrides all others
|
258
|
+
if a.id.to_s == b.remote_id.to_s || b.id.to_s == a.remote_id.to_s
|
259
|
+
debug_log "Found exact ID match between #{a.id} and #{b.id}" if @debug
|
260
|
+
return 100
|
261
|
+
end
|
262
|
+
|
263
|
+
# Check for exact ID match in remote_id field
|
264
|
+
if a.remote_id.to_s == b.id.to_s || b.remote_id.to_s == a.id.to_s
|
265
|
+
debug_log "Found exact ID match in remote_id: a.remote_id=#{a.remote_id}, b.id=#{b.id}" if @debug
|
266
|
+
return 100
|
267
|
+
end
|
268
|
+
|
269
|
+
# Additional check for remote_id in attributes hash
|
270
|
+
begin
|
271
|
+
a_remote_id = a.instance_variable_get(:@attributes)[:remote_id].to_s rescue nil
|
272
|
+
b_remote_id = b.instance_variable_get(:@attributes)[:remote_id].to_s rescue nil
|
273
|
+
|
274
|
+
if (a_remote_id && !a_remote_id.empty? && a_remote_id == b.id.to_s) ||
|
275
|
+
(b_remote_id && !b_remote_id.empty? && b_remote_id == a.id.to_s)
|
276
|
+
debug_log "Found exact ID match in attributes hash: a.attributes[:remote_id]=#{a_remote_id}, b.id=#{b.id}" if @debug
|
277
|
+
return 100
|
278
|
+
end
|
279
|
+
rescue => e
|
280
|
+
debug_log "Error checking remote_id in attributes: #{e.message}" if @debug
|
281
|
+
end
|
282
|
+
|
283
|
+
# Date comparison - must be same date
|
284
|
+
# Convert to string for comparison to handle different date object types
|
285
|
+
# and normalize format to YYYY-MM-DD
|
286
|
+
debug_log "Raw dates: a.date=#{a.date.inspect} (#{a.date.class}), b.date=#{b.date.inspect} (#{b.date.class})" if @debug
|
287
|
+
|
288
|
+
# Normalize dates to YYYY-MM-DD format
|
289
|
+
a_date = normalize_date(a.date)
|
290
|
+
b_date = normalize_date(b.date)
|
291
|
+
|
292
|
+
debug_log "Normalized dates: a_date=#{a_date}, b_date=#{b_date}" if @debug
|
293
|
+
|
294
|
+
if a_date != b_date
|
295
|
+
debug_log "Date mismatch: #{a_date} vs #{b_date}" if @debug
|
296
|
+
return 0
|
297
|
+
end
|
298
|
+
|
140
299
|
score = 0
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
300
|
+
|
301
|
+
# Task matching is important (30 points)
|
302
|
+
if a.task&.id == b.task&.id
|
303
|
+
score += 30
|
304
|
+
debug_log "Task match: +30 points" if @debug
|
305
|
+
end
|
306
|
+
|
307
|
+
# Description matching (up to 30 points)
|
308
|
+
if a.description.to_s.strip.empty? && b.description.to_s.strip.empty?
|
309
|
+
# Both empty descriptions - consider it a match for this attribute
|
310
|
+
score += 30
|
311
|
+
debug_log "Empty description match: +30 points" if @debug
|
312
|
+
else
|
313
|
+
# Use fuzzy matching for non-empty descriptions
|
314
|
+
_, description_match_score = FuzzyMatch.new([a.description.to_s]).find_with_score(b.description.to_s)
|
315
|
+
if description_match_score
|
316
|
+
desc_points = (description_match_score * 30.0).to_i
|
317
|
+
score += desc_points
|
318
|
+
debug_log "Description match (#{description_match_score}): +#{desc_points} points" if @debug
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
# Hours matching (up to 40 points)
|
323
|
+
# Exact hour match gets full points
|
324
|
+
if a.hours == b.hours
|
325
|
+
score += 40
|
326
|
+
debug_log "Exact hours match: +40 points" if @debug
|
327
|
+
else
|
328
|
+
# Otherwise use the clamped difference score
|
329
|
+
hours_points = (clamped_factored_diff_score(a.hours, b.hours) * 40.0).to_i
|
330
|
+
score += hours_points
|
331
|
+
debug_log "Hours similarity (#{a.hours} vs #{b.hours}): +#{hours_points} points" if @debug
|
332
|
+
end
|
333
|
+
|
334
|
+
debug_log "Final score for #{a.id} vs #{b.id}: #{score}" if @debug
|
149
335
|
|
150
336
|
score
|
151
337
|
end
|
152
|
-
|
338
|
+
|
339
|
+
# Helper method to normalize dates to YYYY-MM-DD format
|
340
|
+
def normalize_date(date_value)
|
341
|
+
return nil if date_value.nil?
|
342
|
+
|
343
|
+
date_str = date_value.to_s
|
344
|
+
|
345
|
+
# First try to extract YYYY-MM-DD from ISO format
|
346
|
+
date_str = date_str.split("T").first.strip if date_str.include?("T")
|
347
|
+
|
348
|
+
# Handle different date formats
|
349
|
+
begin
|
350
|
+
# Try to parse as Date object if it's not already in YYYY-MM-DD format
|
351
|
+
date_str = Date.parse(date_str).strftime("%Y-%m-%d") unless date_str =~ /^\d{4}-\d{2}-\d{2}$/
|
352
|
+
rescue StandardError => e
|
353
|
+
debug_log "Error normalizing date '#{date_str}': #{e.message}" if @debug
|
354
|
+
# If parsing fails, return the original string
|
355
|
+
end
|
356
|
+
|
357
|
+
date_str
|
358
|
+
end
|
359
|
+
|
360
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
153
361
|
|
154
362
|
def fetch_assigned_projects
|
155
|
-
|
156
|
-
|
363
|
+
# Use .projects.assigned for the source, standard .projects for the target
|
364
|
+
source_filters = @filters.fetch(:source, {}).merge(active: "true")
|
365
|
+
# Get the proxy, then fetch all results into the instance variable
|
366
|
+
@source_projects = @source.projects.assigned.where(source_filters).all
|
367
|
+
debug_log "Found #{@source_projects.size} source projects:"
|
368
|
+
@source_projects.each do |project|
|
369
|
+
debug_log " Source Project: #{project.id} - #{project.name} (#{project.identifier})"
|
370
|
+
debug_log " Tasks:"
|
371
|
+
project.tasks.each do |task|
|
372
|
+
debug_log " Task: #{task.id} - #{task.name}"
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
target_filters = @filters.fetch(:target, {}).merge(active: "true")
|
377
|
+
# Get the proxy, then fetch all results into the instance variable
|
378
|
+
@target_projects = @target.projects.where(target_filters).all
|
379
|
+
debug_log "Found #{@target_projects.size} target projects:"
|
380
|
+
@target_projects.each do |project|
|
381
|
+
debug_log " Target Project: #{project.id} - #{project.name} (#{project.identifier})"
|
382
|
+
debug_log " Tasks:"
|
383
|
+
project.tasks.each do |task|
|
384
|
+
debug_log " Task: #{task.id} - #{task.name}"
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
# NOTE: The @source_projects and @target_projects are now Arrays of entities,
|
389
|
+
# not CollectionProxy or EntityCollection objects.
|
157
390
|
end
|
158
391
|
|
159
392
|
def build_initial_mappings
|
@@ -162,21 +395,63 @@ module MOCO
|
|
162
395
|
next unless source_project
|
163
396
|
|
164
397
|
@project_mapping[source_project.id] = target_project
|
398
|
+
debug_log "Mapped source project #{source_project.id} (#{source_project.name}) to target project #{target_project.id} (#{target_project.name})"
|
399
|
+
|
165
400
|
target_project.tasks.each do |target_task|
|
166
401
|
source_task = match_task(target_task, source_project)
|
167
|
-
|
402
|
+
if source_task
|
403
|
+
@task_mapping[source_task.id] = target_task
|
404
|
+
debug_log " Mapped source task #{source_task.id} (#{source_task.name}) to target task #{target_task.id} (#{target_task.name})"
|
405
|
+
else
|
406
|
+
debug_log " No matching source task found for target task #{target_task.id} (#{target_task.name})"
|
407
|
+
end
|
168
408
|
end
|
169
409
|
end
|
410
|
+
|
411
|
+
# Log the final mappings
|
412
|
+
debug_log "Final project mappings:"
|
413
|
+
@project_mapping.each do |source_id, target_project|
|
414
|
+
debug_log " Source project #{source_id} -> Target project #{target_project.id} (#{target_project.name})"
|
415
|
+
end
|
416
|
+
|
417
|
+
debug_log "Final task mappings:"
|
418
|
+
@task_mapping.each do |source_id, target_task|
|
419
|
+
debug_log " Source task #{source_id} -> Target task #{target_task.id} (#{target_task.name})"
|
420
|
+
end
|
170
421
|
end
|
171
422
|
|
172
423
|
def match_project(target_project)
|
173
|
-
|
174
|
-
|
424
|
+
# Create array of search objects manually since we can't call map on EntityCollection
|
425
|
+
searchable_projects = []
|
426
|
+
|
427
|
+
# Manually iterate since we can't rely on Enumerable methods
|
428
|
+
@source_projects.each do |project|
|
429
|
+
debug_log "Checking source project: #{project.inspect}" if @debug
|
430
|
+
searchable_projects << { original: project, name: project.name }
|
431
|
+
end
|
432
|
+
|
433
|
+
matcher = FuzzyMatch.new(searchable_projects, read: :name)
|
434
|
+
match = matcher.find(target_project.name, threshold: @project_match_threshold)
|
435
|
+
match[:original] if match
|
175
436
|
end
|
176
437
|
|
177
438
|
def match_task(target_task, source_project)
|
178
|
-
|
179
|
-
|
439
|
+
# Get tasks from the source project
|
440
|
+
tasks = source_project.tasks
|
441
|
+
|
442
|
+
# Create array of search objects manually since we can't rely on Enumerable methods
|
443
|
+
|
444
|
+
# Manually iterate through tasks
|
445
|
+
searchable_tasks = tasks.map do |task|
|
446
|
+
{ original: task, name: task.name }
|
447
|
+
end
|
448
|
+
|
449
|
+
# Only proceed if we have tasks to match against
|
450
|
+
return nil if searchable_tasks.empty?
|
451
|
+
|
452
|
+
matcher = FuzzyMatch.new(searchable_tasks, read: :name)
|
453
|
+
match = matcher.find(target_task.name, threshold: @task_match_threshold)
|
454
|
+
match[:original] if match
|
180
455
|
end
|
181
456
|
end
|
182
457
|
end
|
data/lib/moco/version.rb
CHANGED
data/lib/moco.rb
CHANGED
@@ -1,8 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "active_support"
|
4
|
+
require "active_support/core_ext"
|
5
|
+
require "active_support/inflector"
|
6
|
+
|
3
7
|
require_relative "moco/version"
|
4
|
-
|
5
|
-
|
8
|
+
|
9
|
+
# Core classes needed by entities
|
10
|
+
require_relative "moco/connection"
|
11
|
+
require_relative "moco/collection_proxy"
|
12
|
+
require_relative "moco/nested_collection_proxy"
|
13
|
+
|
14
|
+
# New API (v2)
|
15
|
+
require_relative "moco/entities/base_entity"
|
16
|
+
require_relative "moco/entities/project"
|
17
|
+
require_relative "moco/entities/activity"
|
18
|
+
require_relative "moco/entities/user"
|
19
|
+
require_relative "moco/entities/company"
|
20
|
+
require_relative "moco/entities/task"
|
21
|
+
require_relative "moco/entities/invoice"
|
22
|
+
require_relative "moco/entities/deal"
|
23
|
+
require_relative "moco/entities/expense"
|
24
|
+
require_relative "moco/entities/web_hook"
|
25
|
+
require_relative "moco/entities/schedule"
|
26
|
+
require_relative "moco/entities/presence"
|
27
|
+
require_relative "moco/entities/holiday"
|
28
|
+
require_relative "moco/entities/planning_entry"
|
29
|
+
require_relative "moco/client"
|
30
|
+
require_relative "moco/entity_collection"
|
31
|
+
|
6
32
|
require_relative "moco/sync"
|
7
33
|
|
8
34
|
module MOCO
|
data/moco.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/moco/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "moco-ruby"
|
7
|
+
spec.version = MOCO::VERSION
|
8
|
+
spec.authors = ["Teal Bauer"]
|
9
|
+
spec.email = ["rubygems@teal.is"]
|
10
|
+
|
11
|
+
spec.summary = "A Ruby Gem to interact with the MOCO (mocoapp.com) API."
|
12
|
+
spec.homepage = "https://github.com/starsong-consulting/moco-ruby"
|
13
|
+
spec.required_ruby_version = ">= 3.2.0"
|
14
|
+
spec.license = "Apache-2.0"
|
15
|
+
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
17
|
+
spec.metadata["source_code_uri"] = "https://github.com/starsong-consulting/moco-ruby"
|
18
|
+
spec.metadata["changelog_uri"] = "https://github.com/starsong-consulting/moco-ruby/blob/main/CHANGELOG.md"
|
19
|
+
|
20
|
+
# Specify which files should be added to the gem when it is released.
|
21
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
22
|
+
spec.files = Dir.chdir(__dir__) do
|
23
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
24
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
|
25
|
+
end
|
26
|
+
end
|
27
|
+
spec.bindir = "exe"
|
28
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
29
|
+
spec.require_paths = ["lib"]
|
30
|
+
|
31
|
+
spec.add_dependency "activesupport", "~> 7.0"
|
32
|
+
spec.add_dependency "faraday", "~> 2.9.0"
|
33
|
+
spec.add_dependency "fuzzy_match", "~> 2.1.0"
|
34
|
+
|
35
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
36
|
+
end
|