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 +4 -4
- data/README.md +1 -0
- data/docs/date_conversion_analysis.md +113 -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 +49 -0
- data/lib/taskchampion/version.rb +1 -1
- data/lib/taskchampion.rb +19 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9c5bf1065611d68b14549f0c6930e630f6e79483699d2ad05d2e1211da8192ac
|
4
|
+
data.tar.gz: f9cd13e961c3b814c4eb8474e22e585803e24f426f92e49dbb24c716640c52ee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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(())
|
@@ -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))?;
|
data/lib/taskchampion/version.rb
CHANGED
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.
|
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-
|
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
|