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 +4 -4
- data/docs/ANNOTATION_IMPLEMENTATION.md +642 -0
- data/docs/API_REFERENCE.md +9 -2
- data/examples/basic_usage.rb +55 -0
- data/examples/undo_demo.rb +1 -1
- data/ext/taskchampion/src/task.rs +34 -0
- data/lib/taskchampion/version.rb +1 -1
- data/lib/taskchampion.rb +28 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d661bc07cbe2c900550df0973e28fbc8ca3ed34fb8f607e91bdff8824239a6dc
|
|
4
|
+
data.tar.gz: e4c5b2908fcac0ec63dcaa1c9e179a6a0b17701cb9c04c884738b4977f86ed64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
data/docs/API_REFERENCE.md
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
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)
|
data/examples/basic_usage.rb
CHANGED
|
@@ -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
|
|
data/examples/undo_demo.rb
CHANGED
|
@@ -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))?;
|
data/lib/taskchampion/version.rb
CHANGED
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.
|
|
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-
|
|
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
|