taskchampion-rb 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cafd2930e70aef0ac31c03c98e3d806cd1fba49408ce69b4bcb5d41074465c3f
4
- data.tar.gz: 23b8948a759f8cc7b28e2cb68d65d40750943bd05bf127a87f5e25bf80116900
3
+ metadata.gz: 9c5bf1065611d68b14549f0c6930e630f6e79483699d2ad05d2e1211da8192ac
4
+ data.tar.gz: f9cd13e961c3b814c4eb8474e22e585803e24f426f92e49dbb24c716640c52ee
5
5
  SHA512:
6
- metadata.gz: 2ff558c7674c2ff686e08ec9e220baa966edf438353e42832a566771cbcb2348c329d34fa27937b7bc9b71c0fdd7721f6e3086a675a5baa4e54f2e4e102600a5
7
- data.tar.gz: 34dfcc7ea08bc2f90b846d3a19b1526731a0142d1cc6d09a867923bd6620be3abc13602eab12f8022c85dfc10b3f97502c62419808db3b942808489efbd51488
6
+ metadata.gz: 2724376c451663195d870dedf33dae60e50058909bb35ab89dad2c93b80e30b0aeacb19f0d412c41d2805a5c5fd9c447da5224808d060ddd241c06cabbfa4994
7
+ data.tar.gz: d6f75a71a7e4a040fe471c741c0f1842d09f36101009842896f8c74c9335aea8e5d72cc2fb707cb2088590835b508d632e2adc1c0103e136fc79d8b58b35be93
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,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.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/taskchampion"
5
+
6
+ # Create a replica for this demo
7
+ replica = Taskchampion::Replica.new_in_memory
8
+
9
+ puts "=== TaskChampion Undo/History Demo ==="
10
+
11
+ # Create first task with undo point
12
+ puts "\n1. Creating first task..."
13
+ ops1 = Taskchampion::Operations.new
14
+ ops1.push(Taskchampion::Operation.undo_point)
15
+ task1 = replica.create_task("550e8400-e29b-41d4-a716-446655440000", ops1)
16
+ task1.set_description("First task", ops1)
17
+ replica.commit_operations(ops1)
18
+
19
+ # Create second task with undo point
20
+ puts "2. Creating second task..."
21
+ ops2 = Taskchampion::Operations.new
22
+ ops2.push(Taskchampion::Operation.undo_point)
23
+ task2 = replica.create_task("550e8400-e29b-41d4-a716-446655440001", ops2)
24
+ task2.set_description("Second task", ops2)
25
+ task2.set_value("project", "home", ops2)
26
+ replica.commit_operations(ops2)
27
+
28
+ # Update task2's description to show old_value -> new_value
29
+ puts "\n3. Updating second task's description..."
30
+ ops3 = Taskchampion::Operations.new
31
+ task2.set_description("Updated second task", ops3)
32
+ replica.commit_operations(ops3)
33
+
34
+ # Show current tasks
35
+ puts "\n4. Current tasks:"
36
+ replica.all_tasks.each do |uuid, task|
37
+ puts " - #{task.description} (#{uuid})"
38
+ end
39
+
40
+ # Show task history for task2
41
+ puts "\n5. History for second task:"
42
+ task2_ops = replica.task_operations(task2.uuid)
43
+ puts " Operations count: #{task2_ops.length}"
44
+ task2_ops.to_a.each_with_index do |op, i|
45
+ puts " #{i + 1}. #{op.operation_type}"
46
+ if op.operation_type == :update
47
+ old_val = op.old_value.nil? ? "(none)" : op.old_value
48
+ new_val = op.value.nil? ? "(none)" : op.value
49
+ puts " Details: #{op.property} changed from #{old_val} to #{new_val} at #{op.timestamp}"
50
+ end
51
+ end
52
+
53
+ # Check undo points available
54
+ puts "\n6. Undo points available: #{replica.num_undo_points}"
55
+
56
+ # Show what would be undone
57
+ puts "\n7. Operations that would be undone:"
58
+ undo_ops = replica.undo_operations
59
+ puts " Operations to undo: #{undo_ops.length}"
60
+ undo_ops.to_a.each_with_index do |op, i|
61
+ puts " #{i + 1}. #{op.operation_type}"
62
+ end
63
+
64
+ # Perform undo
65
+ puts "\n8. Performing undo..."
66
+ result = replica.undo!
67
+ puts " Undo successful: #{result}"
68
+
69
+ # Show tasks after undo
70
+ puts "\n9. Tasks after first undo:"
71
+ tasks = replica.all_tasks
72
+ if tasks.empty?
73
+ puts " No tasks"
74
+ else
75
+ tasks.each do |uuid, task|
76
+ puts " - #{task.description} (#{uuid})"
77
+ end
78
+ end
79
+
80
+ # Show remaining undo points
81
+ puts "\n10. Remaining undo points: #{replica.num_undo_points}"
82
+
83
+ # Perform another undo
84
+ if replica.num_undo_points > 0
85
+ puts "\n11. Performing second undo..."
86
+ result = replica.undo!
87
+ puts " Undo successful: #{result}"
88
+
89
+ puts "\n12. Tasks after second undo:"
90
+ tasks = replica.all_tasks
91
+ if tasks.empty?
92
+ puts " No tasks remaining"
93
+ else
94
+ tasks.each do |uuid, task|
95
+ puts " - #{task.description} (#{uuid})"
96
+ end
97
+ end
98
+
99
+ puts "\n13. Final undo points: #{replica.num_undo_points}"
100
+ else
101
+ puts "\n11. No more undo points available"
102
+ end
103
+
104
+ puts "\n=== Demo Complete ==="
@@ -137,6 +137,15 @@ impl Operations {
137
137
  }
138
138
  Ok(())
139
139
  }
140
+
141
+ // Internal method for creating Operations from TaskChampion operations
142
+ pub(crate) fn from_tc_operations(tc_ops: Vec<taskchampion::Operation>) -> Self {
143
+ let mut operations = TCOperations::new();
144
+ for op in tc_ops {
145
+ operations.push(op);
146
+ }
147
+ Operations(ThreadBound::new(RefCell::new(operations)))
148
+ }
140
149
  }
141
150
 
142
151
  // Note: AsRef and AsMut cannot be implemented with RefCell
@@ -93,6 +93,20 @@ impl Replica {
93
93
  Ok(hash)
94
94
  }
95
95
 
96
+ fn pending_tasks(&self) -> Result<RArray, Error> {
97
+ let mut tc_replica = self.0.get_mut()?;
98
+
99
+ let tc_tasks = tc_replica.pending_tasks().map_err(into_error)?;
100
+
101
+ let array = RArray::new();
102
+ for tc_task in tc_tasks {
103
+ let ruby_task = crate::task::Task::from_tc_task(tc_task);
104
+ array.push(ruby_task)?;
105
+ }
106
+
107
+ Ok(array)
108
+ }
109
+
96
110
  fn task_data(&self, uuid: String) -> Result<Value, Error> {
97
111
  let mut tc_replica = self.0.get_mut()?;
98
112
 
@@ -261,19 +275,37 @@ impl Replica {
261
275
  Ok(tc_replica.num_undo_points().map_err(into_error)?)
262
276
  }
263
277
 
264
- fn pending_tasks(&self) -> Result<RArray, Error> {
278
+ fn get_task_operations(&self, uuid: String) -> Result<Value, Error> {
265
279
  let mut tc_replica = self.0.get_mut()?;
280
+ let tc_uuid = uuid2tc(&uuid)?;
266
281
 
267
- let tc_tasks = tc_replica.pending_tasks().map_err(into_error)?;
282
+ let tc_operations = tc_replica.get_task_operations(tc_uuid).map_err(into_error)?;
283
+ let operations = Operations::from_tc_operations(tc_operations);
268
284
 
269
- let array = RArray::new();
270
- for tc_task in tc_tasks {
271
- let ruby_task = crate::task::Task::from_tc_task(tc_task);
272
- array.push(ruby_task)?;
273
- }
285
+ Ok(operations.into_value())
286
+ }
274
287
 
275
- Ok(array)
288
+ fn get_undo_operations(&self) -> Result<Value, Error> {
289
+ let mut tc_replica = self.0.get_mut()?;
290
+
291
+ let tc_operations = tc_replica.get_undo_operations().map_err(into_error)?;
292
+ let operations = Operations::from_tc_operations(tc_operations);
293
+
294
+ Ok(operations.into_value())
276
295
  }
296
+
297
+ fn commit_reversed_operations(&self, operations: &Operations) -> Result<bool, Error> {
298
+ let mut tc_replica = self.0.get_mut()?;
299
+
300
+ // Convert Operations to TaskChampion Operations
301
+ let tc_operations = operations.clone_inner()?;
302
+
303
+ // Commit the reversed operations
304
+ let success = tc_replica.commit_reversed_operations(tc_operations).map_err(into_error)?;
305
+
306
+ Ok(success)
307
+ }
308
+
277
309
  }
278
310
 
279
311
  pub fn init(module: &RModule) -> Result<(), Error> {
@@ -299,6 +331,9 @@ pub fn init(module: &RModule) -> Result<(), Error> {
299
331
  class.define_method("expire_tasks", method!(Replica::expire_tasks, 0))?;
300
332
  class.define_method("num_local_operations", method!(Replica::num_local_operations, 0))?;
301
333
  class.define_method("num_undo_points", method!(Replica::num_undo_points, 0))?;
334
+ class.define_method("get_task_operations", method!(Replica::get_task_operations, 1))?;
335
+ class.define_method("get_undo_operations", method!(Replica::get_undo_operations, 0))?;
336
+ class.define_method("commit_reversed_operations", method!(Replica::commit_reversed_operations, 1))?;
302
337
  class.define_method("pending_tasks", method!(Replica::pending_tasks, 0))?;
303
338
 
304
339
  Ok(())
@@ -293,6 +293,53 @@ impl Task {
293
293
  Ok(())
294
294
  }
295
295
 
296
+ fn set_timestamp(&self, property: String, timestamp: Value, operations: &crate::operations::Operations) -> Result<(), Error> {
297
+ if property.trim().is_empty() {
298
+ return Err(Error::new(
299
+ crate::error::validation_error(),
300
+ "Property name cannot be empty or whitespace-only"
301
+ ));
302
+ }
303
+
304
+ let mut task = self.0.get_mut()?;
305
+ let timestamp_datetime = ruby_to_option(timestamp, ruby_to_datetime)?;
306
+
307
+ // Convert timestamp to Unix timestamp string, or None for clearing
308
+ let timestamp_str = timestamp_datetime.map(|dt| dt.timestamp().to_string());
309
+
310
+ operations.with_inner_mut(|ops| {
311
+ task.set_value(&property, timestamp_str, ops)
312
+ })?;
313
+ Ok(())
314
+ }
315
+
316
+ fn get_timestamp(&self, property: String) -> Result<Value, Error> {
317
+ if property.trim().is_empty() {
318
+ return Err(Error::new(
319
+ crate::error::validation_error(),
320
+ "Property name cannot be empty or whitespace-only"
321
+ ));
322
+ }
323
+
324
+ let task = self.0.get()?;
325
+
326
+ // Get the value as string and attempt to parse as Unix timestamp
327
+ match task.get_value(&property) {
328
+ Some(timestamp_str) => {
329
+ // Parse the string as Unix timestamp (seconds since epoch)
330
+ if let Ok(timestamp_secs) = timestamp_str.parse::<i64>() {
331
+ use chrono::{DateTime, Utc};
332
+ if let Some(dt) = DateTime::from_timestamp(timestamp_secs, 0) {
333
+ return datetime_to_ruby(dt);
334
+ }
335
+ }
336
+ // If parsing fails, return nil
337
+ Ok(().into_value())
338
+ },
339
+ None => Ok(().into_value()) // Return nil if property doesn't exist
340
+ }
341
+ }
342
+
296
343
  fn set_uda(&self, namespace: String, key: String, value: String, operations: &crate::operations::Operations) -> Result<(), Error> {
297
344
  if namespace.trim().is_empty() {
298
345
  return Err(Error::new(
@@ -400,6 +447,8 @@ pub fn init(module: &RModule) -> Result<(), Error> {
400
447
  class.define_method("set_due", method!(Task::set_due, 2))?;
401
448
  class.define_method("set_entry", method!(Task::set_entry, 2))?;
402
449
  class.define_method("set_value", method!(Task::set_value, 3))?;
450
+ class.define_method("set_timestamp", method!(Task::set_timestamp, 3))?;
451
+ class.define_method("get_timestamp", method!(Task::get_timestamp, 1))?;
403
452
  class.define_method("set_uda", method!(Task::set_uda, 4))?;
404
453
  class.define_method("delete_uda", method!(Task::delete_uda, 3))?;
405
454
  class.define_method("done", method!(Task::done, 1))?;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taskchampion
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/taskchampion.rb CHANGED
@@ -37,5 +37,24 @@ 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
40
59
  end
41
60
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taskchampion-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Case
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-08-18 00:00:00.000000000 Z
11
+ date: 2025-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -44,6 +44,7 @@ files:
44
44
  - docs/API_REFERENCE.md
45
45
  - docs/THREAD_SAFETY.md
46
46
  - docs/breakthrough.md
47
+ - docs/date_conversion_analysis.md
47
48
  - docs/description.md
48
49
  - docs/errors.md
49
50
  - docs/phase_3_plan.md
@@ -52,6 +53,7 @@ files:
52
53
  - examples/basic_usage.rb
53
54
  - examples/pending_tasks.rb
54
55
  - examples/sync_workflow.rb
56
+ - examples/undo_demo.rb
55
57
  - ext/taskchampion/Cargo.toml
56
58
  - ext/taskchampion/extconf.rb
57
59
  - ext/taskchampion/src/access_mode.rs