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 +4 -4
- data/README.md +1 -0
- data/docs/ANNOTATION_IMPLEMENTATION.md +642 -0
- data/docs/API_REFERENCE.md +9 -2
- data/docs/date_conversion_analysis.md +113 -0
- data/examples/basic_usage.rb +55 -0
- data/examples/undo_demo.rb +104 -0
- data/ext/taskchampion/src/operations.rs +9 -0
- data/ext/taskchampion/src/replica.rs +43 -8
- data/ext/taskchampion/src/task.rs +83 -0
- data/lib/taskchampion/version.rb +1 -1
- data/lib/taskchampion.rb +47 -0
- metadata +5 -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
|
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`
|
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)
|
|
@@ -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.
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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))?;
|
data/lib/taskchampion/version.rb
CHANGED
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.
|
|
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,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
|