A file-based task manager
0
fork

Configure Feed

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

Implement task properties

Tasks now have arbitrary key/value properties (built on the existing
attrs storage), plus four synthetic properties computed on read:

- state: open | archived (from task location)
- has-links: true | false
- references: comma-separated [[tsk-N]] links found in body
- referenced-by: comma-separated [[tsk-N]] backlinks

CLI:

- tsk prop list <id> list every property on a task
- tsk prop set <id> <k> [v] set a property (value optional for unary)
- tsk prop unset <id> <k> remove a property
- tsk prop find <k> [v] print task ids with property k (matching v
if provided). Includes synthetic props,
so e.g. `tsk prop find state archived`
works.

Tests cover round-tripping stored properties, presence-of-key search,
synthetic property visibility, and unset. Run against both backends.

Tasks for the example "calculated" properties (parent/child-of,
duplicates, source, assigned) are pushed onto the stack and
deprioritized to the bottom; they need their own bidirectional
maintenance logic and are scoped separately.

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

+219
+68
src/main.rs
··· 221 221 /// task data is copied into refs/tsk/* and the on-disk files are removed. 222 222 Migrate, 223 223 224 + /// Get/set/find tasks by property. Properties are arbitrary key/value 225 + /// pairs stored alongside a task; some are synthetic (state, has-links, 226 + /// references, referenced-by) and computed on read. 227 + Prop { 228 + #[command(subcommand)] 229 + action: PropAction, 230 + }, 231 + 224 232 /// Manage namespaces within a git-backed workspace. Namespaces let multiple 225 233 /// people share the same git repo without sharing tasks; refs live under 226 234 /// refs/tsk/<namespace>/. ··· 237 245 #[command(flatten)] 238 246 task_id: TaskId, 239 247 }, 248 + } 249 + 250 + #[derive(Subcommand)] 251 + enum PropAction { 252 + /// List all properties on a task (stored + synthetic). 253 + List { 254 + #[command(flatten)] 255 + task_id: TaskId, 256 + }, 257 + /// Set a property. Value may be omitted for unary properties. 258 + Set { 259 + #[command(flatten)] 260 + task_id: TaskId, 261 + key: String, 262 + value: Option<String>, 263 + }, 264 + /// Remove a property from a task. No-op if not set. 265 + Unset { 266 + #[command(flatten)] 267 + task_id: TaskId, 268 + key: String, 269 + }, 270 + /// Find every task whose property KEY equals VALUE (or that has KEY set 271 + /// at all when VALUE is omitted). 272 + Find { key: String, value: Option<String> }, 240 273 } 241 274 242 275 #[derive(Subcommand)] ··· 389 422 Commands::Export { output } => command_export(dir, output), 390 423 Commands::Migrate => command_migrate(dir), 391 424 Commands::Reopen { task_id } => command_reopen(dir, task_id), 425 + Commands::Prop { action } => command_prop(dir, action), 392 426 Commands::Namespace { action } => command_namespace(dir, action), 393 427 Commands::Switch { name } => command_namespace_switch(dir, &name), 394 428 } ··· 725 759 "Migrated workspace to git refs (git dir: {})", 726 760 git_dir.display() 727 761 ); 762 + Ok(()) 763 + } 764 + 765 + fn command_prop(dir: PathBuf, action: PropAction) -> Result<()> { 766 + let ws = Workspace::from_path(dir)?; 767 + match action { 768 + PropAction::List { task_id } => { 769 + let id = ws.task(task_id.into())?.id; 770 + for (k, v) in ws.properties(id)? { 771 + if v.is_empty() { 772 + println!("{k}"); 773 + } else { 774 + println!("{k}\t{v}"); 775 + } 776 + } 777 + } 778 + PropAction::Set { 779 + task_id, 780 + key, 781 + value, 782 + } => { 783 + let id = ws.task(task_id.into())?.id; 784 + ws.set_property(id, &key, value.as_deref().unwrap_or(""))?; 785 + } 786 + PropAction::Unset { task_id, key } => { 787 + let id = ws.task(task_id.into())?.id; 788 + ws.unset_property(id, &key)?; 789 + } 790 + PropAction::Find { key, value } => { 791 + for id in ws.find_by_property(&key, value.as_deref())? { 792 + println!("{id}"); 793 + } 794 + } 795 + } 728 796 Ok(()) 729 797 } 730 798
+151
src/workspace.rs
··· 258 258 Ok(()) 259 259 } 260 260 261 + /// Set a single property (a.k.a attribute) on a task. Empty value is 262 + /// allowed for unary properties. 263 + pub fn set_property(&self, id: Id, key: &str, value: &str) -> Result<()> { 264 + let mut attrs = backend::read_attrs(self.store(), id)?; 265 + attrs.insert(key.to_string(), value.to_string()); 266 + backend::write_attrs(self.store(), id, &attrs) 267 + } 268 + 269 + /// Remove a property from a task. No-op if not present. 270 + pub fn unset_property(&self, id: Id, key: &str) -> Result<()> { 271 + let mut attrs = backend::read_attrs(self.store(), id)?; 272 + if attrs.remove(key).is_some() { 273 + backend::write_attrs(self.store(), id, &attrs)?; 274 + } 275 + Ok(()) 276 + } 277 + 278 + /// All properties on a task, both stored and synthetic (state, has-links, 279 + /// references, referenced-by). 280 + pub fn properties(&self, id: Id) -> Result<BTreeMap<String, String>> { 281 + let mut props = backend::read_attrs(self.store(), id)?; 282 + let synth = self.synthetic_properties(id)?; 283 + for (k, v) in synth { 284 + props.entry(k).or_insert(v); 285 + } 286 + Ok(props) 287 + } 288 + 289 + fn synthetic_properties(&self, id: Id) -> Result<BTreeMap<String, String>> { 290 + let mut out = BTreeMap::new(); 291 + let Some((_, body, loc)) = backend::read_task(self.store(), id)? else { 292 + return Ok(out); 293 + }; 294 + out.insert( 295 + "state".into(), 296 + match loc { 297 + Loc::Active => "open".into(), 298 + Loc::Archived => "archived".into(), 299 + }, 300 + ); 301 + let parsed = parse_task(&format!("\n\n{body}")); 302 + let refs: Vec<String> = parsed 303 + .as_ref() 304 + .map(|p| { 305 + p.intenal_links() 306 + .iter() 307 + .map(|i| format!("[[{i}]]")) 308 + .collect() 309 + }) 310 + .unwrap_or_default(); 311 + out.insert( 312 + "has-links".into(), 313 + if refs.is_empty() { "false" } else { "true" }.into(), 314 + ); 315 + if !refs.is_empty() { 316 + out.insert("references".into(), refs.join(",")); 317 + } 318 + let backrefs = backend::read_backlinks(self.store(), id)?; 319 + if !backrefs.is_empty() { 320 + let joined: Vec<String> = backrefs.iter().map(|i| format!("[[{i}]]")).collect(); 321 + out.insert("referenced-by".into(), joined.join(",")); 322 + } 323 + Ok(out) 324 + } 325 + 326 + /// Find every task whose property `key` is set (and equals `value`, if 327 + /// provided). Scans both active and archived. Includes synthetic 328 + /// properties so `state=archived`, `has-links=true`, etc. work. 329 + pub fn find_by_property(&self, key: &str, value: Option<&str>) -> Result<Vec<Id>> { 330 + let mut ids: Vec<Id> = backend::list_active(self.store())?; 331 + ids.extend(backend::list_archive(self.store())?); 332 + ids.sort_by_key(|i| i.0); 333 + ids.dedup(); 334 + Ok(ids 335 + .into_iter() 336 + .filter_map(|id| { 337 + let props = self.properties(id).ok()?; 338 + let v = props.get(key)?; 339 + if value.is_none_or(|target| v == target) { 340 + Some(id) 341 + } else { 342 + None 343 + } 344 + }) 345 + .collect()) 346 + } 347 + 261 348 pub fn handle_metadata(&self, tsk: &Task, pre_links: Option<HashSet<Id>>) -> Result<()> { 262 349 if let Some(parsed_task) = parse_task(&tsk.to_string()) { 263 350 let internal_links = parsed_task.intenal_links(); ··· 1279 1366 assert!(fws.git_push_refs("origin").is_err()); 1280 1367 assert!(fws.git_pull_refs("origin").is_err()); 1281 1368 assert!(fws.configure_git_remote_refspecs("origin").is_err()); 1369 + } 1370 + 1371 + #[test] 1372 + fn test_properties_set_unset_list_find() { 1373 + let (_d, file, git) = setup_dual(); 1374 + for ws in [&file, &git] { 1375 + // Push two tasks; mark one with priority=high. 1376 + let t1 = ws.new_task("first".into(), "body".into()).unwrap(); 1377 + let id1 = t1.id; 1378 + ws.push_task(t1).unwrap(); 1379 + let t2 = ws 1380 + .new_task("second".into(), "see [[tsk-1]]".into()) 1381 + .unwrap(); 1382 + let id2 = t2.id; 1383 + ws.handle_metadata(&t2, None).unwrap(); 1384 + ws.push_task(t2).unwrap(); 1385 + 1386 + ws.set_property(id1, "priority", "high").unwrap(); 1387 + ws.set_property(id1, "tag", "").unwrap(); 1388 + 1389 + // Stored properties round-trip. 1390 + let props = ws.properties(id1).unwrap(); 1391 + assert_eq!(props.get("priority").map(String::as_str), Some("high")); 1392 + assert_eq!(props.get("tag").map(String::as_str), Some("")); 1393 + 1394 + // Synthetic properties present. 1395 + assert_eq!(props.get("state").map(String::as_str), Some("open")); 1396 + assert_eq!(props.get("has-links").map(String::as_str), Some("false")); 1397 + // referenced-by on id1 contains id2 (the linker). 1398 + assert!( 1399 + props 1400 + .get("referenced-by") 1401 + .unwrap() 1402 + .contains(&format!("[[{id2}]]")) 1403 + ); 1404 + 1405 + let props2 = ws.properties(id2).unwrap(); 1406 + assert_eq!(props2.get("has-links").map(String::as_str), Some("true")); 1407 + assert!( 1408 + props2 1409 + .get("references") 1410 + .unwrap() 1411 + .contains(&format!("[[{id1}]]")) 1412 + ); 1413 + 1414 + // Find by stored property + value. 1415 + let by_priority = ws.find_by_property("priority", Some("high")).unwrap(); 1416 + assert_eq!(by_priority, vec![id1]); 1417 + // Find by presence (any value). 1418 + let any_priority = ws.find_by_property("priority", None).unwrap(); 1419 + assert_eq!(any_priority, vec![id1]); 1420 + // Find by synthetic property. 1421 + let open = ws.find_by_property("state", Some("open")).unwrap(); 1422 + assert!(open.contains(&id1) && open.contains(&id2)); 1423 + ws.drop(TaskIdentifier::Id(id2)).unwrap(); 1424 + let archived = ws.find_by_property("state", Some("archived")).unwrap(); 1425 + assert_eq!(archived, vec![id2]); 1426 + 1427 + // Unset removes the property. 1428 + ws.unset_property(id1, "priority").unwrap(); 1429 + assert!(ws.properties(id1).unwrap().get("priority").is_none()); 1430 + // Unset of non-existent is fine. 1431 + ws.unset_property(id1, "nope").unwrap(); 1432 + } 1282 1433 } 1283 1434 1284 1435 #[test]