A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fzf-pick prop set's key and value when not supplied

tsk prop set <id> — fzf-pick key from existing keys, then value
from existing values for that key
tsk prop set <id> <key> — fzf-pick value only
tsk prop set <id> <key> <v> — direct (existing behavior)
tsk prop set <id> -l — also include URLs and [[tsk-N]] refs parsed
from the task body as value candidates

The fzf list always carries a `<new>` sentinel so the user can type a
fresh string at a prompt instead of picking from history. Helpful when
seeding a new property name or a value that hasn't been used before.

Tests cover the underlying candidate queries (keys, values-for-key,
body candidates) on both backends; the fzf interaction itself remains
manual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+138 -3
+60 -3
src/main.rs
··· 24 24 Ok(current_dir()?) 25 25 } 26 26 27 + const NEW_SENTINEL: &str = "<new>"; 28 + 29 + fn prompt_line(prompt: &str) -> Result<String> { 30 + use std::io::Write as _; 31 + eprint!("{prompt}"); 32 + io::stderr().flush()?; 33 + let mut s = String::new(); 34 + io::stdin().read_line(&mut s)?; 35 + Ok(s.trim_end_matches(['\n', '\r']).to_string()) 36 + } 37 + 27 38 fn parse_id(s: &str) -> std::result::Result<Id, &'static str> { 28 39 Id::from_str(s).map_err(|_| "Unable to parse tsk- ID") 29 40 } ··· 284 295 #[command(flatten)] 285 296 task_id: TaskId, 286 297 }, 287 - /// Set a property. Value may be omitted for unary properties. 298 + /// Set a property. With both KEY and VALUE supplied, sets directly. 299 + /// With KEY but no VALUE, fzf-picks a value from existing values for 300 + /// that key (and, with -l, also from links/URLs in the task body). 301 + /// With neither, fzf-picks the key first, then the value. The fzf list 302 + /// always includes a `<new>` sentinel for entering a fresh string. 288 303 Set { 289 304 #[command(flatten)] 290 305 task_id: TaskId, 291 - key: String, 306 + /// Property name. If omitted, the user is prompted via fzf. 307 + key: Option<String>, 308 + /// New value. If omitted, the user is prompted via fzf. 292 309 value: Option<String>, 310 + /// Also include links/URLs parsed from the task body as value 311 + /// candidates. 312 + #[arg(short = 'l', default_value_t = false)] 313 + from_body: bool, 293 314 }, 294 315 /// Remove a property from a task. No-op if not set. 295 316 Unset { ··· 968 989 task_id, 969 990 key, 970 991 value, 992 + from_body, 971 993 } => { 972 994 let id = ws.task(task_id.into())?.id; 973 - ws.set_property(id, &key, value.as_deref().unwrap_or(""))?; 995 + let key = match key { 996 + Some(k) => k, 997 + None => { 998 + let mut candidates = ws.all_property_keys()?; 999 + candidates.push(NEW_SENTINEL.to_string()); 1000 + let picked = fzf::select::<_, String, _>(candidates, ["--prompt=property> "])? 1001 + .ok_or_else(|| errors::Error::Parse("No property selected".into()))?; 1002 + if picked == NEW_SENTINEL { 1003 + prompt_line("new property name: ")? 1004 + } else { 1005 + picked 1006 + } 1007 + } 1008 + }; 1009 + let value = match value { 1010 + Some(v) => v, 1011 + None => { 1012 + let mut candidates = ws.property_values_for(&key)?; 1013 + if from_body { 1014 + for c in ws.body_candidates(id)? { 1015 + if !candidates.contains(&c) { 1016 + candidates.push(c); 1017 + } 1018 + } 1019 + } 1020 + candidates.push(NEW_SENTINEL.to_string()); 1021 + let picked = fzf::select::<_, String, _>(candidates, ["--prompt=value> "])? 1022 + .ok_or_else(|| errors::Error::Parse("No value selected".into()))?; 1023 + if picked == NEW_SENTINEL { 1024 + prompt_line("new value (empty for unary): ")? 1025 + } else { 1026 + picked 1027 + } 1028 + } 1029 + }; 1030 + ws.set_property(id, &key, &value)?; 974 1031 } 975 1032 PropAction::Unset { task_id, key } => { 976 1033 let id = ws.task(task_id.into())?.id;
+78
src/workspace.rs
··· 571 571 Ok(out) 572 572 } 573 573 574 + /// Every property key that has ever been set on any task in this 575 + /// namespace, sorted alphabetically. 576 + pub fn all_property_keys(&self) -> Result<Vec<String>> { 577 + let mut seen: std::collections::BTreeSet<String> = Default::default(); 578 + let mut ids: Vec<Id> = backend::list_active(self.store())?; 579 + ids.extend(backend::list_archive(self.store())?); 580 + for id in ids { 581 + for k in backend::read_attrs(self.store(), id)?.into_keys() { 582 + seen.insert(k); 583 + } 584 + } 585 + Ok(seen.into_iter().collect()) 586 + } 587 + 588 + /// Every distinct value seen for a given property `key` across the 589 + /// workspace, sorted alphabetically. 590 + pub fn property_values_for(&self, key: &str) -> Result<Vec<String>> { 591 + let mut seen: std::collections::BTreeSet<String> = Default::default(); 592 + let mut ids: Vec<Id> = backend::list_active(self.store())?; 593 + ids.extend(backend::list_archive(self.store())?); 594 + for id in ids { 595 + if let Some(v) = backend::read_attrs(self.store(), id)?.get(key) { 596 + seen.insert(v.clone()); 597 + } 598 + } 599 + Ok(seen.into_iter().collect()) 600 + } 601 + 602 + /// Candidate values pulled from a task's body: every link the parser 603 + /// found, rendered as `[[tsk-N]]` / `[[ns-N]]` / URL strings. 604 + pub fn body_candidates(&self, id: Id) -> Result<Vec<String>> { 605 + let task = self.task(TaskIdentifier::Id(id))?; 606 + let Some(parsed) = parse_task(&task.to_string()) else { 607 + return Ok(Vec::new()); 608 + }; 609 + Ok(parsed 610 + .links 611 + .iter() 612 + .map(|l| match l { 613 + crate::task::ParsedLink::External(u) => u.to_string(), 614 + crate::task::ParsedLink::Internal(i) => format!("[[{i}]]"), 615 + crate::task::ParsedLink::Foreign { prefix, id } => format!("[[{prefix}-{id}]]"), 616 + }) 617 + .collect()) 618 + } 619 + 574 620 /// Find every task whose property `key` is set (and equals `value`, if 575 621 /// provided). Scans both active and archived. Includes synthetic 576 622 /// properties so `state=archived`, `has-links=true`, etc. work. ··· 2046 2092 zip.file_names().map(|s| s.to_string()).collect(); 2047 2093 assert!(names.contains(&format!("log/{}", id.0))); 2048 2094 std::fs::remove_file(&dest).unwrap(); 2095 + } 2096 + } 2097 + 2098 + #[test] 2099 + fn test_property_candidate_queries() { 2100 + let (_d, file, git) = setup_dual(); 2101 + for ws in [&file, &git] { 2102 + let t1 = ws 2103 + .new_task("a".into(), "see <https://x.example>".into()) 2104 + .unwrap(); 2105 + let id1 = t1.id; 2106 + ws.push_task(t1).unwrap(); 2107 + let t2 = ws.new_task("b".into(), "and [[tsk-1]]".into()).unwrap(); 2108 + let id2 = t2.id; 2109 + ws.push_task(t2).unwrap(); 2110 + ws.set_property(id1, "priority", "high").unwrap(); 2111 + ws.set_property(id2, "priority", "low").unwrap(); 2112 + ws.set_property(id1, "tag", "urgent").unwrap(); 2113 + 2114 + let mut keys = ws.all_property_keys().unwrap(); 2115 + keys.sort(); 2116 + assert_eq!(keys, vec!["priority".to_string(), "tag".to_string()]); 2117 + 2118 + let mut vals = ws.property_values_for("priority").unwrap(); 2119 + vals.sort(); 2120 + assert_eq!(vals, vec!["high".to_string(), "low".to_string()]); 2121 + assert!(ws.property_values_for("missing").unwrap().is_empty()); 2122 + 2123 + let body_cands = ws.body_candidates(id1).unwrap(); 2124 + assert!(body_cands.iter().any(|c| c.contains("x.example"))); 2125 + let body_cands = ws.body_candidates(id2).unwrap(); 2126 + assert!(body_cands.iter().any(|c| c == &format!("[[{id1}]]"))); 2049 2127 } 2050 2128 } 2051 2129