taskchampion-rb 0.8.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: 9c5bf1065611d68b14549f0c6930e630f6e79483699d2ad05d2e1211da8192ac
4
- data.tar.gz: f9cd13e961c3b814c4eb8474e22e585803e24f426f92e49dbb24c716640c52ee
3
+ metadata.gz: d661bc07cbe2c900550df0973e28fbc8ca3ed34fb8f607e91bdff8824239a6dc
4
+ data.tar.gz: e4c5b2908fcac0ec63dcaa1c9e179a6a0b17701cb9c04c884738b4977f86ed64
5
5
  SHA512:
6
- metadata.gz: 2724376c451663195d870dedf33dae60e50058909bb35ab89dad2c93b80e30b0aeacb19f0d412c41d2805a5c5fd9c447da5224808d060ddd241c06cabbfa4994
7
- data.tar.gz: d6f75a71a7e4a040fe471c741c0f1842d09f36101009842896f8c74c9335aea8e5d72cc2fb707cb2088590835b508d632e2adc1c0103e136fc79d8b58b35be93
6
+ metadata.gz: 7aa047cb7dc2adb780e8c2616f726126df2e07bc53e8a357d507d4742ef099a438ed126a7f5849bb43e86136b14dc8044a6038fe7104e94c9a633db6d61101d1
7
+ data.tar.gz: 5fb6d41847c431265d85114505b385e36d05c9e5dc7006c92c3c1e29a89adb015a2486f7620b613b28a8132bb1b30f3e6e4d777ee1ba81e623e17aaae7cc441b
@@ -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)
@@ -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
 
@@ -101,4 +101,4 @@ else
101
101
  puts "\n11. No more undo points available"
102
102
  end
103
103
 
104
- puts "\n=== Demo Complete ==="
104
+ puts "\n=== Demo Complete ==="
@@ -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)?;
@@ -444,6 +476,8 @@ pub fn init(module: &RModule) -> Result<(), Error> {
444
476
  class.define_method("add_tag", method!(Task::add_tag, 2))?;
445
477
  class.define_method("remove_tag", method!(Task::remove_tag, 2))?;
446
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))?;
447
481
  class.define_method("set_due", method!(Task::set_due, 2))?;
448
482
  class.define_method("set_entry", method!(Task::set_entry, 2))?;
449
483
  class.define_method("set_value", method!(Task::set_value, 3))?;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taskchampion
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/taskchampion.rb CHANGED
@@ -57,4 +57,32 @@ module Taskchampion
57
57
  commit_undo!(ops)
58
58
  end
59
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
87
+ end
60
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.8.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-09-24 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,6 +41,7 @@ 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