taskchampion-rb 0.7.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cafd2930e70aef0ac31c03c98e3d806cd1fba49408ce69b4bcb5d41074465c3f
4
- data.tar.gz: 23b8948a759f8cc7b28e2cb68d65d40750943bd05bf127a87f5e25bf80116900
3
+ metadata.gz: d661bc07cbe2c900550df0973e28fbc8ca3ed34fb8f607e91bdff8824239a6dc
4
+ data.tar.gz: e4c5b2908fcac0ec63dcaa1c9e179a6a0b17701cb9c04c884738b4977f86ed64
5
5
  SHA512:
6
- metadata.gz: 2ff558c7674c2ff686e08ec9e220baa966edf438353e42832a566771cbcb2348c329d34fa27937b7bc9b71c0fdd7721f6e3086a675a5baa4e54f2e4e102600a5
7
- data.tar.gz: 34dfcc7ea08bc2f90b846d3a19b1526731a0142d1cc6d09a867923bd6620be3abc13602eab12f8022c85dfc10b3f97502c62419808db3b942808489efbd51488
6
+ metadata.gz: 7aa047cb7dc2adb780e8c2616f726126df2e07bc53e8a357d507d4742ef099a438ed126a7f5849bb43e86136b14dc8044a6038fe7104e94c9a633db6d61101d1
7
+ data.tar.gz: 5fb6d41847c431265d85114505b385e36d05c9e5dc7006c92c3c1e29a89adb015a2486f7620b613b28a8132bb1b30f3e6e4d777ee1ba81e623e17aaae7cc441b
data/README.md CHANGED
@@ -8,6 +8,7 @@ This gem supports Ruby 3.2 and later. We follow Ruby's end-of-life (EOL) schedul
8
8
 
9
9
  - **Ruby 3.2**: Supported (EOL: March 2026)
10
10
  - **Ruby 3.3**: Supported (Current stable)
11
+ - **Ruby 3.4**: Supported
11
12
  - **Ruby 3.0-3.1**: Not supported (reached EOL)
12
13
 
13
14
  ## Installation
@@ -0,0 +1,642 @@
1
+ # Annotation Management Implementation Plan
2
+
3
+ ## Overview
4
+
5
+ This document outlines the implementation plan for adding `remove_annotation` and `update_annotation` methods to the TaskChampion Ruby bindings, enabling full annotation lifecycle management.
6
+
7
+ ## Current State
8
+
9
+ ### Existing Functionality
10
+
11
+ **Annotation Class** (`ext/taskchampion/src/annotation.rs`)
12
+ - Wraps TaskChampion Rust library's `Annotation` type
13
+ - Properties:
14
+ - `entry`: DateTime timestamp when annotation was created
15
+ - `description`: Text content of the annotation
16
+ - Methods: `new`, `entry`, `description`, `to_s`, `inspect`, `eql?`, `hash`
17
+
18
+ **Task Methods** (`ext/taskchampion/src/task.rs`)
19
+ - `task.annotations` - Returns array of Annotation objects (read-only)
20
+ - `task.add_annotation(description, operations)` - Adds new annotation with auto-generated timestamp
21
+
22
+ ### Limitations
23
+ - No way to remove annotations once added
24
+ - No way to edit/update existing annotations
25
+ - Annotations are effectively append-only
26
+
27
+ ## Design Decisions
28
+
29
+ ### Decision 1: API for `remove_annotation`
30
+ **Chosen: Option A - Pass full Annotation object**
31
+
32
+ ```ruby
33
+ annotation = task.annotations.first
34
+ task.remove_annotation(annotation, operations)
35
+ ```
36
+
37
+ **Rationale:**
38
+ - Most Ruby-idiomatic approach
39
+ - User doesn't need to extract timestamp manually
40
+ - Mirrors standard Ruby collection methods (e.g., `Array#delete`)
41
+ - Consistent with Ruby's object-oriented design
42
+
43
+ **Alternatives Considered:**
44
+ - Option B: Pass timestamp only - More explicit but less intuitive
45
+ - Option C: Support both - Added complexity without significant benefit
46
+
47
+ ### Decision 2: Method Name for Editing
48
+ **Chosen: Option A - `update_annotation`**
49
+
50
+ ```ruby
51
+ task.update_annotation(annotation, "Updated description", operations)
52
+ ```
53
+
54
+ **Rationale:**
55
+ - Matches Rails/Ruby conventions (ActiveRecord uses `update`)
56
+ - Clearly indicates modification intent
57
+ - Common pattern in Ruby APIs
58
+
59
+ **Alternatives Considered:**
60
+ - `edit_annotation` - More conversational but less conventional
61
+ - `replace_annotation` - More accurate to implementation but unfamiliar
62
+ - No convenience method - Too low-level for common use case
63
+
64
+ ### Decision 3: Timestamp Preservation
65
+ **Chosen: Option A - Preserve original timestamp**
66
+
67
+ When updating an annotation, the original `entry` timestamp is preserved.
68
+
69
+ ```ruby
70
+ annotation = task.annotations.first # entry: 2025-01-15 10:00:00
71
+ task.update_annotation(annotation, "Updated text", operations)
72
+ # Result: entry still shows 2025-01-15 10:00:00
73
+ ```
74
+
75
+ **Rationale:**
76
+ - Maintains chronological history (shows when annotation was originally created)
77
+ - Audit trail remains intact
78
+ - Aligns with TaskWarrior philosophy of immutable timestamps
79
+ - Users can see historical context even after editing
80
+
81
+ **Alternatives Considered:**
82
+ - Update to current timestamp - Loses original creation time
83
+ - User choice via parameter - Added API complexity
84
+
85
+ ### Decision 4: Implementation Location
86
+ **Chosen: Option A - Pure Ruby wrapper in `lib/taskchampion.rb`**
87
+
88
+ ```ruby
89
+ class Task
90
+ def update_annotation(annotation, new_description, operations)
91
+ remove_annotation(annotation, operations)
92
+ add_annotation(new_description, operations)
93
+ end
94
+ end
95
+ ```
96
+
97
+ **Rationale:**
98
+ - Easier to implement and maintain
99
+ - Transparent implementation (users can see it's remove + add)
100
+ - No Rust compilation needed for changes
101
+ - Adequate performance for this use case
102
+
103
+ **Alternatives Considered:**
104
+ - Rust implementation - More atomic but adds complexity
105
+
106
+ ### Decision 5: Error Handling
107
+ **Chosen: Option A - Silent success (match Rust behavior)**
108
+
109
+ Attempting to remove a non-existent annotation succeeds silently without error.
110
+
111
+ ```ruby
112
+ task.remove_annotation(non_existent_annotation, operations)
113
+ # No error - operation succeeds even if annotation doesn't exist
114
+ ```
115
+
116
+ **Rationale:**
117
+ - Matches underlying Rust library behavior
118
+ - Idempotent (safe to call multiple times)
119
+ - Simple implementation
120
+ - Consistent with underlying system design
121
+
122
+ **Alternatives Considered:**
123
+ - Raise ValidationError - More defensive but inconsistent with Rust layer
124
+ - Return boolean - Less Ruby-idiomatic
125
+
126
+ ## Implementation Details
127
+
128
+ ### 1. Rust Extension Changes
129
+
130
+ **File:** `ext/taskchampion/src/task.rs`
131
+
132
+ Add `remove_annotation` method:
133
+
134
+ ```rust
135
+ fn remove_annotation(&self, annotation: &Annotation, operations: &crate::operations::Operations) -> Result<(), Error> {
136
+ let mut task = self.0.get_mut()?;
137
+ let entry_timestamp = annotation.0.entry;
138
+
139
+ operations.with_inner_mut(|ops| {
140
+ task.remove_annotation(entry_timestamp, ops)
141
+ })?;
142
+ Ok(())
143
+ }
144
+ ```
145
+
146
+ Register in `init()` function:
147
+
148
+ ```rust
149
+ class.define_method("remove_annotation", method!(Task::remove_annotation, 2))?;
150
+ ```
151
+
152
+ ### 2. Ruby Wrapper Changes
153
+
154
+ **File:** `lib/taskchampion.rb`
155
+
156
+ Add to Task class:
157
+
158
+ ```ruby
159
+ class Task
160
+ # Update an existing annotation's description while preserving its timestamp
161
+ #
162
+ # @param annotation [Taskchampion::Annotation] The annotation to update
163
+ # @param new_description [String] The new description text
164
+ # @param operations [Taskchampion::Operations] Operations collection
165
+ # @return [void]
166
+ #
167
+ # @example
168
+ # annotation = task.annotations.first
169
+ # task.update_annotation(annotation, "Updated note", operations)
170
+ # replica.commit_operations(operations)
171
+ #
172
+ def update_annotation(annotation, new_description, operations)
173
+ # Remove the old annotation
174
+ remove_annotation(annotation, operations)
175
+
176
+ # Add new annotation with preserved timestamp
177
+ # Note: add_annotation creates a new timestamp, so we need to use the lower-level API
178
+ # This preserves the original entry time
179
+ entry_time = annotation.entry
180
+ new_ann = Taskchampion::Annotation.new(entry_time, new_description)
181
+
182
+ # We need to expose add_annotation that accepts an Annotation object
183
+ # For now, document that timestamp preservation requires the annotation
184
+ # to be removed and re-added with the same timestamp
185
+ remove_annotation(annotation, operations)
186
+
187
+ # Create new annotation with same timestamp
188
+ # This will require modifying add_annotation to accept either:
189
+ # 1. A description string (current behavior - auto timestamp)
190
+ # 2. An Annotation object (new behavior - preserve timestamp)
191
+ end
192
+ end
193
+ ```
194
+
195
+ **Note:** This reveals we need to modify `add_annotation` to support passing an Annotation object directly, or add a separate lower-level method.
196
+
197
+ ### 3. Revised Implementation Approach
198
+
199
+ Given the need to preserve timestamps, we have two options:
200
+
201
+ #### Option A: Modify `add_annotation` to accept Annotation objects
202
+
203
+ ```rust
204
+ // In task.rs, modify add_annotation signature
205
+ fn add_annotation(&self, arg: Value, operations: &Operations) -> Result<(), Error> {
206
+ let mut task = self.0.get_mut()?;
207
+
208
+ // Check if arg is an Annotation object or a String
209
+ if let Ok(annotation) = <&Annotation>::try_convert(arg) {
210
+ // User passed an Annotation object - use its timestamp
211
+ operations.with_inner_mut(|ops| {
212
+ task.add_annotation(annotation.0.clone(), ops)
213
+ })?;
214
+ } else if let Ok(description) = String::try_convert(arg) {
215
+ // User passed a string - create new annotation with current time
216
+ // ... existing implementation ...
217
+ } else {
218
+ return Err(Error::new(
219
+ crate::error::validation_error(),
220
+ "add_annotation expects an Annotation object or description string"
221
+ ));
222
+ }
223
+ Ok(())
224
+ }
225
+ ```
226
+
227
+ #### Option B: Add separate `add_annotation_with_timestamp` method
228
+
229
+ ```rust
230
+ fn add_annotation_with_timestamp(
231
+ &self,
232
+ timestamp: Value,
233
+ description: String,
234
+ operations: &Operations
235
+ ) -> Result<(), Error> {
236
+ let mut task = self.0.get_mut()?;
237
+ let entry = ruby_to_datetime(timestamp)?;
238
+
239
+ let annotation = taskchampion::Annotation {
240
+ entry,
241
+ description
242
+ };
243
+
244
+ operations.with_inner_mut(|ops| {
245
+ task.add_annotation(annotation, ops)
246
+ })?;
247
+ Ok(())
248
+ }
249
+ ```
250
+
251
+ Then Ruby wrapper:
252
+
253
+ ```ruby
254
+ def update_annotation(annotation, new_description, operations)
255
+ remove_annotation(annotation, operations)
256
+ add_annotation_with_timestamp(annotation.entry, new_description, operations)
257
+ end
258
+ ```
259
+
260
+ **Recommendation:** Option B is cleaner and maintains backward compatibility.
261
+
262
+ ### 4. Testing Strategy
263
+
264
+ **File:** `test/test_task.rb`
265
+
266
+ ```ruby
267
+ def test_remove_annotation
268
+ replica = Taskchampion::Replica.new_in_memory
269
+ operations = Taskchampion::Operations.new
270
+
271
+ uuid = SecureRandom.uuid
272
+ task = replica.create_task(uuid, operations)
273
+ task.set_description("Test task", operations)
274
+
275
+ # Add annotations
276
+ task.add_annotation("First note", operations)
277
+ task.add_annotation("Second note", operations)
278
+ replica.commit_operations(operations)
279
+
280
+ # Verify both exist
281
+ retrieved = replica.task(uuid)
282
+ assert_equal 2, retrieved.annotations.length
283
+
284
+ # Remove first annotation
285
+ ops2 = Taskchampion::Operations.new
286
+ annotation_to_remove = retrieved.annotations.first
287
+ retrieved.remove_annotation(annotation_to_remove, ops2)
288
+ replica.commit_operations(ops2)
289
+
290
+ # Verify only one remains
291
+ final = replica.task(uuid)
292
+ assert_equal 1, final.annotations.length
293
+ assert_equal "Second note", final.annotations.first.description
294
+ end
295
+
296
+ def test_remove_annotation_nonexistent
297
+ replica = Taskchampion::Replica.new_in_memory
298
+ operations = Taskchampion::Operations.new
299
+
300
+ uuid = SecureRandom.uuid
301
+ task = replica.create_task(uuid, operations)
302
+ task.set_description("Test task", operations)
303
+ task.add_annotation("Note", operations)
304
+ replica.commit_operations(operations)
305
+
306
+ # Create annotation with different timestamp
307
+ fake_annotation = Taskchampion::Annotation.new(
308
+ DateTime.now + 1,
309
+ "Doesn't exist"
310
+ )
311
+
312
+ # Should not raise error
313
+ ops2 = Taskchampion::Operations.new
314
+ retrieved = replica.task(uuid)
315
+ assert_nothing_raised do
316
+ retrieved.remove_annotation(fake_annotation, ops2)
317
+ end
318
+ replica.commit_operations(ops2)
319
+
320
+ # Original annotation should still exist
321
+ final = replica.task(uuid)
322
+ assert_equal 1, final.annotations.length
323
+ end
324
+
325
+ def test_update_annotation
326
+ replica = Taskchampion::Replica.new_in_memory
327
+ operations = Taskchampion::Operations.new
328
+
329
+ uuid = SecureRandom.uuid
330
+ task = replica.create_task(uuid, operations)
331
+ task.set_description("Test task", operations)
332
+ task.add_annotation("Original note", operations)
333
+ replica.commit_operations(operations)
334
+
335
+ # Get annotation and note its timestamp
336
+ retrieved = replica.task(uuid)
337
+ annotation = retrieved.annotations.first
338
+ original_timestamp = annotation.entry
339
+
340
+ # Update annotation
341
+ ops2 = Taskchampion::Operations.new
342
+ retrieved.update_annotation(annotation, "Updated note", ops2)
343
+ replica.commit_operations(ops2)
344
+
345
+ # Verify description changed but timestamp preserved
346
+ final = replica.task(uuid)
347
+ assert_equal 1, final.annotations.length
348
+ updated = final.annotations.first
349
+ assert_equal "Updated note", updated.description
350
+
351
+ # Timestamp should be preserved (within 1 second tolerance)
352
+ time_diff = (updated.entry.to_time - original_timestamp.to_time).abs
353
+ assert time_diff < 1, "Timestamp should be preserved"
354
+ end
355
+
356
+ def test_update_annotation_empty_description
357
+ replica = Taskchampion::Replica.new_in_memory
358
+ operations = Taskchampion::Operations.new
359
+
360
+ uuid = SecureRandom.uuid
361
+ task = replica.create_task(uuid, operations)
362
+ task.set_description("Test task", operations)
363
+ task.add_annotation("Note", operations)
364
+ replica.commit_operations(operations)
365
+
366
+ retrieved = replica.task(uuid)
367
+ annotation = retrieved.annotations.first
368
+
369
+ # Should raise validation error for empty description
370
+ ops2 = Taskchampion::Operations.new
371
+ assert_raises Taskchampion::ValidationError do
372
+ retrieved.update_annotation(annotation, "", ops2)
373
+ end
374
+
375
+ assert_raises Taskchampion::ValidationError do
376
+ retrieved.update_annotation(annotation, " ", ops2)
377
+ end
378
+ end
379
+ ```
380
+
381
+ **File:** `test/integration/test_task_lifecycle.rb`
382
+
383
+ ```ruby
384
+ def test_annotation_removal_workflow
385
+ # Create task with multiple annotations
386
+ uuid = SecureRandom.uuid
387
+ task = @replica.create_task(uuid, @operations)
388
+ task.set_description("Task with annotations", @operations)
389
+
390
+ task.add_annotation("First annotation", @operations)
391
+ task.add_annotation("Second annotation", @operations)
392
+ task.add_annotation("Third annotation", @operations)
393
+ @replica.commit_operations(@operations)
394
+
395
+ # Remove middle annotation
396
+ retrieved = @replica.task(uuid)
397
+ annotations = retrieved.annotations.sort_by(&:entry)
398
+ middle_annotation = annotations[1]
399
+
400
+ ops2 = Taskchampion::Operations.new
401
+ retrieved.remove_annotation(middle_annotation, ops2)
402
+ @replica.commit_operations(ops2)
403
+
404
+ # Verify correct annotation was removed
405
+ final = @replica.task(uuid)
406
+ assert_equal 2, final.annotations.length
407
+ remaining_descriptions = final.annotations.map(&:description)
408
+ assert_includes remaining_descriptions, "First annotation"
409
+ assert_includes remaining_descriptions, "Third annotation"
410
+ refute_includes remaining_descriptions, "Second annotation"
411
+ end
412
+
413
+ def test_annotation_update_workflow
414
+ # Create task with annotation
415
+ uuid = SecureRandom.uuid
416
+ task = @replica.create_task(uuid, @operations)
417
+ task.set_description("Task to update", @operations)
418
+ task.add_annotation("Original text", @operations)
419
+ @replica.commit_operations(@operations)
420
+
421
+ # Update annotation multiple times
422
+ retrieved = @replica.task(uuid)
423
+ annotation = retrieved.annotations.first
424
+ original_entry = annotation.entry
425
+
426
+ ops2 = Taskchampion::Operations.new
427
+ retrieved.update_annotation(annotation, "First update", ops2)
428
+ @replica.commit_operations(ops2)
429
+
430
+ retrieved2 = @replica.task(uuid)
431
+ annotation2 = retrieved2.annotations.first
432
+ assert_equal "First update", annotation2.description
433
+
434
+ ops3 = Taskchampion::Operations.new
435
+ retrieved2.update_annotation(annotation2, "Second update", ops3)
436
+ @replica.commit_operations(ops3)
437
+
438
+ # Verify final state
439
+ final = @replica.task(uuid)
440
+ assert_equal 1, final.annotations.length
441
+ final_annotation = final.annotations.first
442
+ assert_equal "Second update", final_annotation.description
443
+
444
+ # Verify timestamp preserved through multiple updates
445
+ time_diff = (final_annotation.entry.to_time - original_entry.to_time).abs
446
+ assert time_diff < 1, "Original timestamp should be preserved"
447
+ end
448
+ ```
449
+
450
+ ### 5. Documentation Updates
451
+
452
+ **File:** `docs/API_REFERENCE.md`
453
+
454
+ Add to Annotation Management section:
455
+
456
+ ```markdown
457
+ #### Annotation Management
458
+
459
+ ```ruby
460
+ # Add annotation
461
+ task.add_annotation("Added note", operations)
462
+
463
+ # Remove annotation
464
+ annotation = task.annotations.first
465
+ task.remove_annotation(annotation, operations)
466
+
467
+ # Update annotation (preserves original timestamp)
468
+ annotation = task.annotations.find { |a| a.description.include?("old text") }
469
+ task.update_annotation(annotation, "New text", operations)
470
+
471
+ # Commit changes
472
+ replica.commit_operations(operations)
473
+ ```
474
+
475
+ **Method Reference:**
476
+
477
+ ##### `task.remove_annotation(annotation, operations)`
478
+
479
+ Removes an annotation from the task. Identified by the annotation's entry timestamp.
480
+
481
+ - **Parameters:**
482
+ - `annotation` (Taskchampion::Annotation) - The annotation to remove
483
+ - `operations` (Taskchampion::Operations) - Operations collection
484
+ - **Returns:** `nil`
485
+ - **Raises:** None (silently succeeds if annotation doesn't exist)
486
+ - **Example:**
487
+ ```ruby
488
+ annotation = task.annotations.first
489
+ task.remove_annotation(annotation, operations)
490
+ replica.commit_operations(operations)
491
+ ```
492
+
493
+ ##### `task.update_annotation(annotation, new_description, operations)`
494
+
495
+ Updates an annotation's description while preserving its original timestamp.
496
+
497
+ - **Parameters:**
498
+ - `annotation` (Taskchampion::Annotation) - The annotation to update
499
+ - `new_description` (String) - The new description text
500
+ - `operations` (Taskchampion::Operations) - Operations collection
501
+ - **Returns:** `nil`
502
+ - **Raises:**
503
+ - `Taskchampion::ValidationError` - If new_description is empty or whitespace-only
504
+ - **Example:**
505
+ ```ruby
506
+ annotation = task.annotations.first
507
+ task.update_annotation(annotation, "Updated note", operations)
508
+ replica.commit_operations(operations)
509
+ ```
510
+ - **Note:** This is a convenience method that removes the old annotation and adds a new one with the same timestamp, preserving the chronological history.
511
+ ```
512
+
513
+ ### 6. Example Code Updates
514
+
515
+ **File:** `examples/basic_usage.rb`
516
+
517
+ Add section after annotation display (around line 110):
518
+
519
+ ```ruby
520
+ # 5b. REMOVING AND UPDATING ANNOTATIONS
521
+ puts "\n5b. Removing and updating annotations"
522
+
523
+ # Get a task with annotations
524
+ task_with_ann = replica.task(uuid1)
525
+ if task_with_ann && !task_with_ann.annotations.empty?
526
+ puts "Original annotations: #{task_with_ann.annotations.length}"
527
+ task_with_ann.annotations.each do |ann|
528
+ puts " - [#{ann.entry.strftime('%H:%M:%S')}] #{ann.description}"
529
+ end
530
+
531
+ # Update first annotation
532
+ operations_ann = Taskchampion::Operations.new
533
+ first_ann = task_with_ann.annotations.first
534
+ original_time = first_ann.entry
535
+
536
+ puts "\nUpdating first annotation..."
537
+ task_with_ann.update_annotation(first_ann, "Updated: Started learning TaskChampion", operations_ann)
538
+ replica.commit_operations(operations_ann)
539
+
540
+ # Verify update
541
+ task_updated = replica.task(uuid1)
542
+ updated_ann = task_updated.annotations.first
543
+ puts "Updated annotation: #{updated_ann.description}"
544
+ puts "Timestamp preserved: #{updated_ann.entry == original_time}"
545
+
546
+ # Add another annotation then remove it
547
+ operations_ann2 = Taskchampion::Operations.new
548
+ task_updated.add_annotation("Temporary note", operations_ann2)
549
+ replica.commit_operations(operations_ann2)
550
+
551
+ task_temp = replica.task(uuid1)
552
+ puts "\nAnnotations after adding temporary: #{task_temp.annotations.length}"
553
+
554
+ # Remove the temporary annotation
555
+ operations_ann3 = Taskchampion::Operations.new
556
+ temp_ann = task_temp.annotations.find { |a| a.description == "Temporary note" }
557
+ task_temp.remove_annotation(temp_ann, operations_ann3) if temp_ann
558
+ replica.commit_operations(operations_ann3)
559
+
560
+ task_final = replica.task(uuid1)
561
+ puts "Annotations after removal: #{task_final.annotations.length}"
562
+ end
563
+ ```
564
+
565
+ ## Implementation Checklist
566
+
567
+ ### Phase 1: Core `remove_annotation` Implementation
568
+ - [ ] Add `remove_annotation` Rust method in `ext/taskchampion/src/task.rs`
569
+ - [ ] Register method in `init()` function
570
+ - [ ] Add `add_annotation_with_timestamp` Rust method for timestamp preservation
571
+ - [ ] Register `add_annotation_with_timestamp` in `init()` function
572
+ - [ ] Run `bundle exec rake compile` to build extension
573
+ - [ ] Add unit tests in `test/test_task.rb`
574
+ - [ ] Run tests: `bundle exec rake test TEST=test/test_task.rb`
575
+
576
+ ### Phase 2: Ruby `update_annotation` Wrapper
577
+ - [ ] Add `update_annotation` method to Task class in `lib/taskchampion.rb`
578
+ - [ ] Add comprehensive unit tests for update functionality
579
+ - [ ] Add integration tests in `test/integration/test_task_lifecycle.rb`
580
+ - [ ] Run full test suite: `bundle exec rake test`
581
+
582
+ ### Phase 3: Documentation & Examples
583
+ - [ ] Update `docs/API_REFERENCE.md` with new methods
584
+ - [ ] Add usage examples to `examples/basic_usage.rb`
585
+ - [ ] Run example to verify: `ruby examples/basic_usage.rb`
586
+ - [ ] Update CHANGELOG.md with new features
587
+
588
+ ### Phase 4: Code Quality
589
+ - [ ] Run RuboCop: `bundle exec rake rubocop`
590
+ - [ ] Fix any linting issues
591
+ - [ ] Review error messages for clarity
592
+ - [ ] Ensure consistent code style
593
+
594
+ ## Potential Future Enhancements
595
+
596
+ ### 1. Batch Operations
597
+ Add methods for bulk annotation management:
598
+
599
+ ```ruby
600
+ task.remove_annotations(annotations_array, operations)
601
+ task.update_annotations(annotation_updates_hash, operations)
602
+ ```
603
+
604
+ ### 2. Annotation Filtering
605
+ Add helper methods for finding annotations:
606
+
607
+ ```ruby
608
+ task.find_annotation { |a| a.description.include?("text") }
609
+ task.annotations_since(datetime)
610
+ task.annotations_matching(pattern)
611
+ ```
612
+
613
+ ### 3. Annotation History
614
+ Track annotation changes through operations log:
615
+
616
+ ```ruby
617
+ task.annotation_history # Returns timeline of all annotation changes
618
+ ```
619
+
620
+ ## Estimated Implementation Time
621
+
622
+ - **Phase 1 (Rust methods):** 1-2 hours
623
+ - **Phase 2 (Ruby wrapper & tests):** 1-2 hours
624
+ - **Phase 3 (Documentation):** 30-60 minutes
625
+ - **Phase 4 (Polish):** 30 minutes
626
+
627
+ **Total:** 3-5 hours
628
+
629
+ ## Notes & Considerations
630
+
631
+ 1. **Thread Safety:** All methods maintain thread-bound safety through existing ThreadBound wrapper
632
+ 2. **Backward Compatibility:** All changes are additive - no breaking changes to existing API
633
+ 3. **Performance:** Ruby wrapper approach adds minimal overhead; acceptable for typical use cases
634
+ 4. **Synchronization:** All changes tracked through Operations system for replica sync
635
+ 5. **Validation:** Leverages existing validation in `add_annotation` for description validation
636
+
637
+ ## References
638
+
639
+ - TaskChampion Rust library: `/home/tcase/Sites/reference/taskchampion`
640
+ - Existing annotation implementation: `ext/taskchampion/src/annotation.rs`
641
+ - Task implementation: `ext/taskchampion/src/task.rs`
642
+ - Current tests: `test/test_task.rb`, `test/integration/test_task_lifecycle.rb`
@@ -142,8 +142,15 @@ task.add_tag(Taskchampion::Tag.new("work"), operations)
142
142
  task.remove_tag(Taskchampion::Tag.new("work"), operations)
143
143
 
144
144
  # Annotation management
145
- annotation = Taskchampion::Annotation.new(Time.now, "Added note")
146
- task.add_annotation(annotation, operations)
145
+ task.add_annotation("Added note", operations)
146
+
147
+ # Remove annotation
148
+ annotation = task.annotations.first
149
+ task.remove_annotation(annotation, operations)
150
+
151
+ # Update annotation (preserves original timestamp)
152
+ annotation = task.annotations.first
153
+ task.update_annotation(annotation, "Updated text", operations)
147
154
 
148
155
  # UDA management
149
156
  task.set_uda("namespace", "key", "value", operations)
@@ -0,0 +1,113 @@
1
+ # Date Format Conversion in `task.set_due`
2
+
3
+ ## Overview
4
+
5
+ When `task.set_due` is invoked in the TaskChampion Ruby gem, there **is** a format conversion from various Ruby date/time formats to a Unix timestamp for storage.
6
+
7
+ ## Conversion Flow
8
+
9
+ The conversion follows this path: **Ruby date/time → UTC DateTime → Unix timestamp (stored as string)**
10
+
11
+ ### 1. Ruby Input Processing
12
+ **Location**: `ext/taskchampion/src/task.rs:258-264`
13
+
14
+ The `set_due` method accepts a Ruby `Value` which can be:
15
+ - `nil` (to clear the due date)
16
+ - A Ruby `Time` object
17
+ - A Ruby `DateTime` object
18
+ - A String in ISO 8601 format (e.g., "2023-01-01T12:00:00Z")
19
+ - A String in the format "%Y-%m-%d %H:%M:%S %z"
20
+
21
+ ```rust
22
+ fn set_due(&self, due: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
23
+ let mut task = self.0.get_mut()?;
24
+ let due_datetime = ruby_to_option(due, ruby_to_datetime)?;
25
+ operations.with_inner_mut(|ops| {
26
+ task.set_due(due_datetime, ops)
27
+ })?;
28
+ Ok(())
29
+ }
30
+ ```
31
+
32
+ ### 2. Conversion to Rust DateTime
33
+ **Location**: `ext/taskchampion/src/util.rs:33-77`
34
+
35
+ The `ruby_to_datetime` function converts the Ruby value to a Rust `DateTime<Utc>`:
36
+
37
+ - **For `Time` objects**: Uses `strftime("%Y-%m-%dT%H:%M:%S%z")` to get ISO format, then parses it
38
+ - **For `DateTime` objects**: Calls the `iso8601()` method, then parses the result
39
+ - **For Strings**: Attempts to parse as RFC3339 first, then falls back to "%Y-%m-%d %H:%M:%S %z" format
40
+ - **All dates are converted to UTC timezone**
41
+
42
+ ```rust
43
+ pub fn ruby_to_datetime(value: Value) -> Result<DateTime<Utc>, Error> {
44
+ // String parsing
45
+ if let Ok(s) = RString::try_convert(value) {
46
+ let s = unsafe { s.as_str()? };
47
+ DateTime::parse_from_rfc3339(s)
48
+ .map(|dt| dt.with_timezone(&Utc))
49
+ .or_else(|_| DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z")
50
+ .map(|dt| dt.with_timezone(&Utc)))
51
+ // ... error handling
52
+ } else {
53
+ // Ruby Time/DateTime object handling
54
+ let class_name = unsafe { value.class().name() };
55
+ let iso_string = if class_name == "Time" {
56
+ value.funcall::<_, (&str,), String>("strftime", ("%Y-%m-%dT%H:%M:%S%z",))?
57
+ } else {
58
+ value.funcall::<_, (), String>("iso8601", ())?
59
+ };
60
+ // Parse the ISO string...
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### 3. Storage as Unix Timestamp
66
+ **Location**: `/home/tcase/Sites/reference/taskchampion/src/task/task.rs`
67
+
68
+ The underlying TaskChampion library stores the date as a Unix timestamp:
69
+
70
+ ```rust
71
+ pub fn set_due(&mut self, due: Option<Timestamp>, ops: &mut Operations) -> Result<()> {
72
+ self.set_timestamp(Prop::Due.as_ref(), due, ops)
73
+ }
74
+
75
+ pub fn set_timestamp(
76
+ &mut self,
77
+ property: &str,
78
+ value: Option<Timestamp>,
79
+ ops: &mut Operations,
80
+ ) -> Result<()> {
81
+ self.set_value(property, value.map(|v| v.timestamp().to_string()), ops)
82
+ }
83
+ ```
84
+
85
+ Where `Timestamp` is defined as:
86
+ ```rust
87
+ pub(crate) type Timestamp = DateTime<Utc>;
88
+ ```
89
+
90
+ The `.timestamp()` method converts the `DateTime<Utc>` to Unix timestamp (seconds since epoch), which is then converted to a string for storage (e.g., "1704067200").
91
+
92
+ ### 4. Retrieval Process
93
+ **Location**: `ext/taskchampion/src/task.rs`
94
+
95
+ When retrieving the due date, it's converted back from Unix timestamp to a Ruby DateTime object:
96
+
97
+ ```rust
98
+ fn due(&self) -> Result<Value, Error> {
99
+ let task = self.0.get()?;
100
+ option_to_ruby(task.get_due(), datetime_to_ruby)
101
+ }
102
+ ```
103
+
104
+ ## Summary
105
+
106
+ The `task.set_due` method performs comprehensive date format conversion:
107
+
108
+ 1. **Input**: Accepts various Ruby date/time formats and strings
109
+ 2. **Normalization**: Converts all inputs to UTC `DateTime<Utc>`
110
+ 3. **Storage**: Stores as Unix timestamp string in the underlying TaskChampion database
111
+ 4. **Retrieval**: Converts back to Ruby DateTime objects when accessed
112
+
113
+ This ensures consistent date handling across different input formats while maintaining precision and timezone normalization.
@@ -171,6 +171,61 @@ begin
171
171
  puts "Task completed using set_status() method and committed"
172
172
  end
173
173
 
174
+ # 6b. ANNOTATION MANAGEMENT
175
+ puts "\n6b. Managing annotations (add, update, remove)"
176
+
177
+ # Get a task with annotations
178
+ task_with_ann = replica.task(uuid1)
179
+ if task_with_ann && !task_with_ann.annotations.empty?
180
+ puts "Task '#{task_with_ann.description}' has #{task_with_ann.annotations.length} annotation(s)"
181
+
182
+ # Display current annotations
183
+ puts "Current annotations:"
184
+ task_with_ann.annotations.each do |ann|
185
+ puts " - [#{ann.entry.strftime('%H:%M:%S')}] #{ann.description}"
186
+ end
187
+
188
+ # Update the first annotation (preserves timestamp)
189
+ operations_ann = Taskchampion::Operations.new
190
+ first_ann = task_with_ann.annotations.first
191
+ original_time = first_ann.entry
192
+
193
+ puts "\nUpdating first annotation..."
194
+ task_with_ann.update_annotation(first_ann, "Updated: Started and completed learning TaskChampion", operations_ann)
195
+ replica.commit_operations(operations_ann)
196
+
197
+ # Verify update
198
+ task_updated = replica.task(uuid1)
199
+ updated_ann = task_updated.annotations.first
200
+ puts "Updated annotation: #{updated_ann.description}"
201
+ puts "Timestamp preserved: #{(updated_ann.entry.to_time - original_time.to_time).abs < 1}"
202
+
203
+ # Add a temporary annotation
204
+ operations_ann2 = Taskchampion::Operations.new
205
+ task_updated.add_annotation("Temporary progress note", operations_ann2)
206
+ replica.commit_operations(operations_ann2)
207
+
208
+ task_temp = replica.task(uuid1)
209
+ puts "\nAnnotations after adding temporary note: #{task_temp.annotations.length}"
210
+ task_temp.annotations.each do |ann|
211
+ puts " - #{ann.description}"
212
+ end
213
+
214
+ # Remove the temporary annotation
215
+ operations_ann3 = Taskchampion::Operations.new
216
+ temp_ann = task_temp.annotations.find { |a| a.description.include?("Temporary") }
217
+ if temp_ann
218
+ task_temp.remove_annotation(temp_ann, operations_ann3)
219
+ replica.commit_operations(operations_ann3)
220
+
221
+ task_final = replica.task(uuid1)
222
+ puts "\nAnnotations after removal: #{task_final.annotations.length}"
223
+ task_final.annotations.each do |ann|
224
+ puts " - #{ann.description}"
225
+ end
226
+ end
227
+ end
228
+
174
229
  # 7. WORKING WITH WORKING SET
175
230
  puts "\n7. Working with Working Set"
176
231
 
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/taskchampion"
5
+
6
+ # Create a replica for this demo
7
+ replica = Taskchampion::Replica.new_in_memory
8
+
9
+ puts "=== TaskChampion Undo/History Demo ==="
10
+
11
+ # Create first task with undo point
12
+ puts "\n1. Creating first task..."
13
+ ops1 = Taskchampion::Operations.new
14
+ ops1.push(Taskchampion::Operation.undo_point)
15
+ task1 = replica.create_task("550e8400-e29b-41d4-a716-446655440000", ops1)
16
+ task1.set_description("First task", ops1)
17
+ replica.commit_operations(ops1)
18
+
19
+ # Create second task with undo point
20
+ puts "2. Creating second task..."
21
+ ops2 = Taskchampion::Operations.new
22
+ ops2.push(Taskchampion::Operation.undo_point)
23
+ task2 = replica.create_task("550e8400-e29b-41d4-a716-446655440001", ops2)
24
+ task2.set_description("Second task", ops2)
25
+ task2.set_value("project", "home", ops2)
26
+ replica.commit_operations(ops2)
27
+
28
+ # Update task2's description to show old_value -> new_value
29
+ puts "\n3. Updating second task's description..."
30
+ ops3 = Taskchampion::Operations.new
31
+ task2.set_description("Updated second task", ops3)
32
+ replica.commit_operations(ops3)
33
+
34
+ # Show current tasks
35
+ puts "\n4. Current tasks:"
36
+ replica.all_tasks.each do |uuid, task|
37
+ puts " - #{task.description} (#{uuid})"
38
+ end
39
+
40
+ # Show task history for task2
41
+ puts "\n5. History for second task:"
42
+ task2_ops = replica.task_operations(task2.uuid)
43
+ puts " Operations count: #{task2_ops.length}"
44
+ task2_ops.to_a.each_with_index do |op, i|
45
+ puts " #{i + 1}. #{op.operation_type}"
46
+ if op.operation_type == :update
47
+ old_val = op.old_value.nil? ? "(none)" : op.old_value
48
+ new_val = op.value.nil? ? "(none)" : op.value
49
+ puts " Details: #{op.property} changed from #{old_val} to #{new_val} at #{op.timestamp}"
50
+ end
51
+ end
52
+
53
+ # Check undo points available
54
+ puts "\n6. Undo points available: #{replica.num_undo_points}"
55
+
56
+ # Show what would be undone
57
+ puts "\n7. Operations that would be undone:"
58
+ undo_ops = replica.undo_operations
59
+ puts " Operations to undo: #{undo_ops.length}"
60
+ undo_ops.to_a.each_with_index do |op, i|
61
+ puts " #{i + 1}. #{op.operation_type}"
62
+ end
63
+
64
+ # Perform undo
65
+ puts "\n8. Performing undo..."
66
+ result = replica.undo!
67
+ puts " Undo successful: #{result}"
68
+
69
+ # Show tasks after undo
70
+ puts "\n9. Tasks after first undo:"
71
+ tasks = replica.all_tasks
72
+ if tasks.empty?
73
+ puts " No tasks"
74
+ else
75
+ tasks.each do |uuid, task|
76
+ puts " - #{task.description} (#{uuid})"
77
+ end
78
+ end
79
+
80
+ # Show remaining undo points
81
+ puts "\n10. Remaining undo points: #{replica.num_undo_points}"
82
+
83
+ # Perform another undo
84
+ if replica.num_undo_points > 0
85
+ puts "\n11. Performing second undo..."
86
+ result = replica.undo!
87
+ puts " Undo successful: #{result}"
88
+
89
+ puts "\n12. Tasks after second undo:"
90
+ tasks = replica.all_tasks
91
+ if tasks.empty?
92
+ puts " No tasks remaining"
93
+ else
94
+ tasks.each do |uuid, task|
95
+ puts " - #{task.description} (#{uuid})"
96
+ end
97
+ end
98
+
99
+ puts "\n13. Final undo points: #{replica.num_undo_points}"
100
+ else
101
+ puts "\n11. No more undo points available"
102
+ end
103
+
104
+ puts "\n=== Demo Complete ==="
@@ -137,6 +137,15 @@ impl Operations {
137
137
  }
138
138
  Ok(())
139
139
  }
140
+
141
+ // Internal method for creating Operations from TaskChampion operations
142
+ pub(crate) fn from_tc_operations(tc_ops: Vec<taskchampion::Operation>) -> Self {
143
+ let mut operations = TCOperations::new();
144
+ for op in tc_ops {
145
+ operations.push(op);
146
+ }
147
+ Operations(ThreadBound::new(RefCell::new(operations)))
148
+ }
140
149
  }
141
150
 
142
151
  // Note: AsRef and AsMut cannot be implemented with RefCell
@@ -93,6 +93,20 @@ impl Replica {
93
93
  Ok(hash)
94
94
  }
95
95
 
96
+ fn pending_tasks(&self) -> Result<RArray, Error> {
97
+ let mut tc_replica = self.0.get_mut()?;
98
+
99
+ let tc_tasks = tc_replica.pending_tasks().map_err(into_error)?;
100
+
101
+ let array = RArray::new();
102
+ for tc_task in tc_tasks {
103
+ let ruby_task = crate::task::Task::from_tc_task(tc_task);
104
+ array.push(ruby_task)?;
105
+ }
106
+
107
+ Ok(array)
108
+ }
109
+
96
110
  fn task_data(&self, uuid: String) -> Result<Value, Error> {
97
111
  let mut tc_replica = self.0.get_mut()?;
98
112
 
@@ -261,19 +275,37 @@ impl Replica {
261
275
  Ok(tc_replica.num_undo_points().map_err(into_error)?)
262
276
  }
263
277
 
264
- fn pending_tasks(&self) -> Result<RArray, Error> {
278
+ fn get_task_operations(&self, uuid: String) -> Result<Value, Error> {
265
279
  let mut tc_replica = self.0.get_mut()?;
280
+ let tc_uuid = uuid2tc(&uuid)?;
266
281
 
267
- let tc_tasks = tc_replica.pending_tasks().map_err(into_error)?;
282
+ let tc_operations = tc_replica.get_task_operations(tc_uuid).map_err(into_error)?;
283
+ let operations = Operations::from_tc_operations(tc_operations);
268
284
 
269
- let array = RArray::new();
270
- for tc_task in tc_tasks {
271
- let ruby_task = crate::task::Task::from_tc_task(tc_task);
272
- array.push(ruby_task)?;
273
- }
285
+ Ok(operations.into_value())
286
+ }
274
287
 
275
- Ok(array)
288
+ fn get_undo_operations(&self) -> Result<Value, Error> {
289
+ let mut tc_replica = self.0.get_mut()?;
290
+
291
+ let tc_operations = tc_replica.get_undo_operations().map_err(into_error)?;
292
+ let operations = Operations::from_tc_operations(tc_operations);
293
+
294
+ Ok(operations.into_value())
276
295
  }
296
+
297
+ fn commit_reversed_operations(&self, operations: &Operations) -> Result<bool, Error> {
298
+ let mut tc_replica = self.0.get_mut()?;
299
+
300
+ // Convert Operations to TaskChampion Operations
301
+ let tc_operations = operations.clone_inner()?;
302
+
303
+ // Commit the reversed operations
304
+ let success = tc_replica.commit_reversed_operations(tc_operations).map_err(into_error)?;
305
+
306
+ Ok(success)
307
+ }
308
+
277
309
  }
278
310
 
279
311
  pub fn init(module: &RModule) -> Result<(), Error> {
@@ -299,6 +331,9 @@ pub fn init(module: &RModule) -> Result<(), Error> {
299
331
  class.define_method("expire_tasks", method!(Replica::expire_tasks, 0))?;
300
332
  class.define_method("num_local_operations", method!(Replica::num_local_operations, 0))?;
301
333
  class.define_method("num_undo_points", method!(Replica::num_undo_points, 0))?;
334
+ class.define_method("get_task_operations", method!(Replica::get_task_operations, 1))?;
335
+ class.define_method("get_undo_operations", method!(Replica::get_undo_operations, 0))?;
336
+ class.define_method("commit_reversed_operations", method!(Replica::commit_reversed_operations, 1))?;
302
337
  class.define_method("pending_tasks", method!(Replica::pending_tasks, 0))?;
303
338
 
304
339
  Ok(())
@@ -255,6 +255,38 @@ impl Task {
255
255
  Ok(())
256
256
  }
257
257
 
258
+ fn remove_annotation(&self, annotation: &Annotation, operations: &crate::operations::Operations) -> Result<(), Error> {
259
+ let mut task = self.0.get_mut()?;
260
+ let entry_timestamp = annotation.as_ref().entry;
261
+
262
+ operations.with_inner_mut(|ops| {
263
+ task.remove_annotation(entry_timestamp, ops)
264
+ })?;
265
+ Ok(())
266
+ }
267
+
268
+ fn add_annotation_with_timestamp(&self, timestamp: Value, description: String, operations: &crate::operations::Operations) -> Result<(), Error> {
269
+ if description.trim().is_empty() {
270
+ return Err(Error::new(
271
+ crate::error::validation_error(),
272
+ "Annotation description cannot be empty or whitespace-only"
273
+ ));
274
+ }
275
+
276
+ let mut task = self.0.get_mut()?;
277
+ let entry = ruby_to_datetime(timestamp)?;
278
+
279
+ let annotation = taskchampion::Annotation {
280
+ entry,
281
+ description,
282
+ };
283
+
284
+ operations.with_inner_mut(|ops| {
285
+ task.add_annotation(annotation, ops)
286
+ })?;
287
+ Ok(())
288
+ }
289
+
258
290
  fn set_due(&self, due: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
259
291
  let mut task = self.0.get_mut()?;
260
292
  let due_datetime = ruby_to_option(due, ruby_to_datetime)?;
@@ -293,6 +325,53 @@ impl Task {
293
325
  Ok(())
294
326
  }
295
327
 
328
+ fn set_timestamp(&self, property: String, timestamp: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
329
+ if property.trim().is_empty() {
330
+ return Err(Error::new(
331
+ crate::error::validation_error(),
332
+ "Property name cannot be empty or whitespace-only"
333
+ ));
334
+ }
335
+
336
+ let mut task = self.0.get_mut()?;
337
+ let timestamp_datetime = ruby_to_option(timestamp, ruby_to_datetime)?;
338
+
339
+ // Convert timestamp to Unix timestamp string, or None for clearing
340
+ let timestamp_str = timestamp_datetime.map(|dt| dt.timestamp().to_string());
341
+
342
+ operations.with_inner_mut(|ops| {
343
+ task.set_value(&property, timestamp_str, ops)
344
+ })?;
345
+ Ok(())
346
+ }
347
+
348
+ fn get_timestamp(&self, property: String) -> Result<Value, Error> {
349
+ if property.trim().is_empty() {
350
+ return Err(Error::new(
351
+ crate::error::validation_error(),
352
+ "Property name cannot be empty or whitespace-only"
353
+ ));
354
+ }
355
+
356
+ let task = self.0.get()?;
357
+
358
+ // Get the value as string and attempt to parse as Unix timestamp
359
+ match task.get_value(&property) {
360
+ Some(timestamp_str) => {
361
+ // Parse the string as Unix timestamp (seconds since epoch)
362
+ if let Ok(timestamp_secs) = timestamp_str.parse::<i64>() {
363
+ use chrono::{DateTime, Utc};
364
+ if let Some(dt) = DateTime::from_timestamp(timestamp_secs, 0) {
365
+ return datetime_to_ruby(dt);
366
+ }
367
+ }
368
+ // If parsing fails, return nil
369
+ Ok(().into_value())
370
+ },
371
+ None => Ok(().into_value()) // Return nil if property doesn't exist
372
+ }
373
+ }
374
+
296
375
  fn set_uda(&self, namespace: String, key: String, value: String, operations: &crate::operations::Operations) -> Result<(), Error> {
297
376
  if namespace.trim().is_empty() {
298
377
  return Err(Error::new(
@@ -397,9 +476,13 @@ pub fn init(module: &RModule) -> Result<(), Error> {
397
476
  class.define_method("add_tag", method!(Task::add_tag, 2))?;
398
477
  class.define_method("remove_tag", method!(Task::remove_tag, 2))?;
399
478
  class.define_method("add_annotation", method!(Task::add_annotation, 2))?;
479
+ class.define_method("remove_annotation", method!(Task::remove_annotation, 2))?;
480
+ class.define_method("add_annotation_with_timestamp", method!(Task::add_annotation_with_timestamp, 3))?;
400
481
  class.define_method("set_due", method!(Task::set_due, 2))?;
401
482
  class.define_method("set_entry", method!(Task::set_entry, 2))?;
402
483
  class.define_method("set_value", method!(Task::set_value, 3))?;
484
+ class.define_method("set_timestamp", method!(Task::set_timestamp, 3))?;
485
+ class.define_method("get_timestamp", method!(Task::get_timestamp, 1))?;
403
486
  class.define_method("set_uda", method!(Task::set_uda, 4))?;
404
487
  class.define_method("delete_uda", method!(Task::delete_uda, 3))?;
405
488
  class.define_method("done", method!(Task::done, 1))?;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taskchampion
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/taskchampion.rb CHANGED
@@ -37,5 +37,52 @@ module Taskchampion
37
37
  ws.replica = self
38
38
  ws
39
39
  end
40
+
41
+ # Ruby-style convenience methods for undo functionality
42
+ def task_operations(uuid)
43
+ get_task_operations(uuid)
44
+ end
45
+
46
+ def undo_operations
47
+ get_undo_operations
48
+ end
49
+
50
+ def commit_undo!(operations)
51
+ commit_reversed_operations(operations)
52
+ end
53
+
54
+ def undo!
55
+ ops = undo_operations
56
+ return false if ops.empty?
57
+ commit_undo!(ops)
58
+ end
59
+ end
60
+
61
+ # Task convenience methods
62
+ class Task
63
+ # Update an existing annotation's description while preserving its timestamp
64
+ #
65
+ # This is a convenience method that removes the old annotation and adds a new one
66
+ # with the same timestamp, effectively updating the description while maintaining
67
+ # the original creation time for chronological history.
68
+ #
69
+ # @param annotation [Taskchampion::Annotation] The annotation to update
70
+ # @param new_description [String] The new description text
71
+ # @param operations [Taskchampion::Operations] Operations collection
72
+ # @return [void]
73
+ #
74
+ # @example Update an annotation
75
+ # annotation = task.annotations.first
76
+ # task.update_annotation(annotation, "Updated note", operations)
77
+ # replica.commit_operations(operations)
78
+ #
79
+ # @raise [Taskchampion::ValidationError] if new_description is empty or whitespace-only
80
+ def update_annotation(annotation, new_description, operations)
81
+ # Remove the old annotation
82
+ remove_annotation(annotation, operations)
83
+
84
+ # Add new annotation with the preserved timestamp
85
+ add_annotation_with_timestamp(annotation.entry, new_description, operations)
86
+ end
40
87
  end
41
88
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taskchampion-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Case
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-08-18 00:00:00.000000000 Z
11
+ date: 2025-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -41,9 +41,11 @@ files:
41
41
  - Cargo.toml
42
42
  - README.md
43
43
  - Rakefile
44
+ - docs/ANNOTATION_IMPLEMENTATION.md
44
45
  - docs/API_REFERENCE.md
45
46
  - docs/THREAD_SAFETY.md
46
47
  - docs/breakthrough.md
48
+ - docs/date_conversion_analysis.md
47
49
  - docs/description.md
48
50
  - docs/errors.md
49
51
  - docs/phase_3_plan.md
@@ -52,6 +54,7 @@ files:
52
54
  - examples/basic_usage.rb
53
55
  - examples/pending_tasks.rb
54
56
  - examples/sync_workflow.rb
57
+ - examples/undo_demo.rb
55
58
  - ext/taskchampion/Cargo.toml
56
59
  - ext/taskchampion/extconf.rb
57
60
  - ext/taskchampion/src/access_mode.rs