Skip to content

Commit 9e75204

Browse files
committed
[Release] v1.1.2 — fix duplicate eid on TTDL-spawned recurrence
1 parent bd26616 commit 9e75204

File tree

8 files changed

+94
-16
lines changed

8 files changed

+94
-16
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@
33
All notable changes to this project will be documented in this file.
44
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55

6+
## [1.1.2] - 2026-03-04
7+
8+
### Fixed
9+
10+
- **Duplicate `eid:` on TTDL-spawned recurrence instances**: completing a
11+
recurring task via `ttdl done` directly caused `todo_lib`'s
12+
`cleanup_cloned_task()` to inherit the parent's `eid:` on the spawned next
13+
instance (only `tmr:` and `spent:` are stripped by the library). The
14+
previous fix in v1.1.0 covered completions triggered *by* `remtodo sync`
15+
but not external completions. A pre-sync dedup pass now detects tasks
16+
sharing an `eid:`, keeps it on the baseline copy (matched against the
17+
stored `task_line_hash`), and strips it from duplicates so the spawn
18+
receives a fresh Reminder on the next sync. Recurring tasks may now be
19+
completed freely via either `remtodo sync` or `ttdl done`.
20+
621
## [1.1.1] - 2026-03-03
722

823
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "remtodo"
3-
version = "1.1.1"
3+
version = "1.1.2"
44
edition = "2021"
55

66
[dependencies]

src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ mod tests {
233233

234234
#[test]
235235
fn expand_tilde_absolute_path_unchanged() {
236-
let p = "/home/user/Notes/Tasks/todo.txt";
236+
let p = "/Users/benb/Notes/Tasks/Todo.md";
237237
assert_eq!(expand_tilde(p), p);
238238
}
239239

src/main.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,69 @@ fn sync_once(
601601
}
602602
let mut current_state = state_opt.unwrap();
603603

604+
// Strip duplicate eid: tags caused by external tools (e.g. TTDL completing a
605+
// recurring task directly). `todo::done()` in todo_lib inherits all tags
606+
// including `eid:`, so the spawned next-instance ends up sharing the eid of the
607+
// completed parent. Our own `collect_recurrence_spawns` strips the eid, but that
608+
// only runs for completions triggered *by* remtodo. When TTDL is used directly
609+
// we must detect and fix the duplication here before the engine sees the tasks.
610+
//
611+
// Rule: for each eid that appears on more than one task, keep the eid on the task
612+
// whose `task_line_hash` matches state (the "baseline" copy). If none match
613+
// state, keep the eid on the first (earliest) occurrence. Strip from all others.
614+
{
615+
use std::collections::HashMap;
616+
let mut first_by_eid: HashMap<String, usize> = HashMap::new();
617+
for (i, t) in current_tasks.iter().enumerate() {
618+
if let Some(eid) = t.tags.get("eid").map(|e| e.as_str()) {
619+
if !eid.is_empty() && !eid.starts_with("na") && !eid.starts_with("ns") {
620+
first_by_eid.entry(eid.to_string()).or_insert(i);
621+
}
622+
}
623+
}
624+
// Collect indices that need their eid stripped.
625+
let mut strip_indices: Vec<usize> = Vec::new();
626+
for (i, t) in current_tasks.iter().enumerate() {
627+
if let Some(eid) = t.tags.get("eid").map(|e| e.as_str()) {
628+
if eid.is_empty() || eid.starts_with("na") || eid.starts_with("ns") {
629+
continue;
630+
}
631+
let canonical = *first_by_eid.get(eid).unwrap_or(&i);
632+
if i != canonical {
633+
// Prefer the copy whose hash matches state as the canonical one.
634+
let state_hash = current_state
635+
.items
636+
.get(eid)
637+
.map(|s| s.task_line_hash)
638+
.unwrap_or(0);
639+
let current_hash = task_line_hash(t);
640+
let canonical_hash = task_line_hash(&current_tasks[canonical]);
641+
if state_hash != 0 && current_hash == state_hash && canonical_hash != state_hash
642+
{
643+
// This copy matches state — it becomes canonical; strip the other.
644+
strip_indices.push(canonical);
645+
*first_by_eid.get_mut(eid).unwrap() = i;
646+
} else {
647+
strip_indices.push(i);
648+
}
649+
}
650+
}
651+
}
652+
for idx in strip_indices {
653+
let eid = current_tasks[idx]
654+
.tags
655+
.get("eid")
656+
.cloned()
657+
.unwrap_or_default();
658+
current_tasks[idx].update_tag("eid:");
659+
debug!(
660+
"Stripped duplicate eid:{} from TTDL-spawned recurrence instance '{}' (index {idx})",
661+
eid,
662+
extract_title(&current_tasks[idx])
663+
);
664+
}
665+
}
666+
604667
if !dry_run {
605668
create_pre_sync_backup(output, &state_path, state_dir)?;
606669
}

src/reminder.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ mod tests {
4242
"completionDate": null,
4343
"creationDate": "2026-02-20",
4444
"lastModifiedDate": "2026-02-20T10:00:00Z",
45-
"notes": "Call back dentist",
45+
"notes": "Get 2% from Costco",
4646
"list": "Tasks"
4747
}"#;
4848
let r: Reminder = serde_json::from_str(json).unwrap();
@@ -58,7 +58,7 @@ mod tests {
5858
r.last_modified_date,
5959
Some("2026-02-20T10:00:00Z".to_string())
6060
);
61-
assert_eq!(r.notes, Some("Call back dentist".to_string()));
61+
assert_eq!(r.notes, Some("Get 2% from Costco".to_string()));
6262
assert_eq!(r.list, "Tasks");
6363
}
6464

src/sync/engine.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5035,11 +5035,11 @@ sticky_tracking = "auto""#;
50355035
// Task as it looks after the user assigned priority C in todo.txt.
50365036
// The To-do push_filter requires @joint AND (@today OR due=..+1d).
50375037
// This task has @joint but no @today and no due → off filter.
5038-
let task = task_from_line(&format!("(C) Folding table @joint eid:{eid}"));
5038+
let task = task_from_line(&format!("(C) Chairs @ Sarah @joint eid:{eid}"));
50395039
let current_hash = task_line_hash(&task);
50405040

50415041
// State: was pulled from To-do with no priority (hash differs → user edited)
5042-
let mut item = synced_item_with_hash(eid, "Folding table", current_hash + 1, false);
5042+
let mut item = synced_item_with_hash(eid, "Chairs @ Sarah", current_hash + 1, false);
50435043
item.fields.list = "To-do".to_string();
50445044

50455045
let state = state_with_items(vec![item]);
@@ -5065,10 +5065,10 @@ sticky_tracking = "auto""#;
50655065
use super::compute_release_set;
50665066

50675067
let eid = "eid-joint-always";
5068-
let task = task_from_line(&format!("(C) Extension cord @joint eid:{eid}"));
5068+
let task = task_from_line(&format!("(C) Tupperware lid @joint eid:{eid}"));
50695069
let current_hash = task_line_hash(&task);
50705070

5071-
let mut item = synced_item_with_hash(eid, "Extension cord", current_hash + 1, false);
5071+
let mut item = synced_item_with_hash(eid, "Tupperware lid", current_hash + 1, false);
50725072
item.fields.list = "To-do".to_string();
50735073

50745074
let state = state_with_items(vec![item]);
@@ -5137,12 +5137,12 @@ sticky_tracking = "auto""#;
51375137
let eid = "eid-real-workflow";
51385138

51395139
// After triage: user added priority C, task has @joint but no @today, no near due.
5140-
let task = task_from_line(&format!("(C) Extension cord @joint eid:{eid} #buy"));
5140+
let task = task_from_line(&format!("(C) Tupperware lid @joint eid:{eid} #buy"));
51415141
let current_hash = task_line_hash(&task);
51425142

51435143
// State: pulled from To-do, no priority (hash differs)
51445144
let mut state_item =
5145-
synced_item_with_hash(eid, "Extension cord #buy", current_hash + 1, false);
5145+
synced_item_with_hash(eid, "Tupperware lid #buy", current_hash + 1, false);
51465146
state_item.fields.list = "To-do".to_string();
51475147
let state = state_with_items(vec![state_item]);
51485148

@@ -5162,7 +5162,7 @@ sticky_tracking = "auto""#;
51625162
);
51635163

51645164
let reminder = ReminderBuilder::new(eid)
5165-
.title("Extension cord #buy")
5165+
.title("Tupperware lid #buy")
51665166
.list("To-do")
51675167
.build();
51685168
let actions = compute_sync_actions_ext(

src/undo.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ mod tests {
357357
completion_date: Some("2026-02-28".to_string()),
358358
creation_date: None,
359359
last_modified_date: None,
360-
notes: Some("call back dentist".to_string()),
360+
notes: Some("2% from Costco".to_string()),
361361
list: "Shopping".to_string(),
362362
};
363363

@@ -370,7 +370,7 @@ mod tests {
370370
assert_eq!(update.is_completed, Some(true));
371371
assert_eq!(update.completion_date, Some(Some("2026-02-28".to_string())));
372372
assert_eq!(update.due_date, Some(Some("2026-03-01".to_string())));
373-
assert_eq!(update.notes, Some(Some("call back dentist".to_string())));
373+
assert_eq!(update.notes, Some(Some("2% from Costco".to_string())));
374374
}
375375

376376
#[test]
@@ -385,7 +385,7 @@ mod tests {
385385
completion_date: None,
386386
creation_date: None,
387387
last_modified_date: None,
388-
notes: Some("fragile".to_string()),
388+
notes: Some("organic".to_string()),
389389
list: "Shopping".to_string(),
390390
};
391391

@@ -395,7 +395,7 @@ mod tests {
395395
assert_eq!(input.list_name, "Shopping");
396396
assert_eq!(input.priority, 5);
397397
assert_eq!(input.due_date, Some("2026-03-01".to_string()));
398-
assert_eq!(input.notes, Some("fragile".to_string()));
398+
assert_eq!(input.notes, Some("organic".to_string()));
399399
assert!(!input.is_completed);
400400
assert!(input.completion_date.is_none());
401401
}

0 commit comments

Comments
 (0)