A file-based task manager
0
fork

Configure Feed

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

Add export command writing the workspace to a zip archive

tsk export writes every blob in the workspace — tasks, archive, attrs,
backlinks, index, next, remotes — to a zip file. Defaults to ./tsk.zip;
override with -o <path>. Works against either backend by iterating the
Store's logical key namespace, so the on-disk layout in the zip is the
same regardless of whether the source is file-backed or git-backed.

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

+225 -2
+131
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "adler2" 7 + version = "2.0.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 + 11 + [[package]] 6 12 name = "anstream" 7 13 version = "0.6.20" 8 14 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 53 59 ] 54 60 55 61 [[package]] 62 + name = "arbitrary" 63 + version = "1.4.2" 64 + source = "registry+https://github.com/rust-lang/crates.io-index" 65 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 66 + dependencies = [ 67 + "derive_arbitrary", 68 + ] 69 + 70 + [[package]] 56 71 name = "bitflags" 57 72 version = "2.9.4" 58 73 source = "registry+https://github.com/rust-lang/crates.io-index" 59 74 checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" 75 + 76 + [[package]] 77 + name = "bumpalo" 78 + version = "3.20.2" 79 + source = "registry+https://github.com/rust-lang/crates.io-index" 80 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 60 81 61 82 [[package]] 62 83 name = "cc" ··· 151 172 ] 152 173 153 174 [[package]] 175 + name = "crc32fast" 176 + version = "1.5.0" 177 + source = "registry+https://github.com/rust-lang/crates.io-index" 178 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 179 + dependencies = [ 180 + "cfg-if", 181 + ] 182 + 183 + [[package]] 184 + name = "crossbeam-utils" 185 + version = "0.8.21" 186 + source = "registry+https://github.com/rust-lang/crates.io-index" 187 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 188 + 189 + [[package]] 190 + name = "derive_arbitrary" 191 + version = "1.4.2" 192 + source = "registry+https://github.com/rust-lang/crates.io-index" 193 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 194 + dependencies = [ 195 + "proc-macro2", 196 + "quote", 197 + "syn", 198 + ] 199 + 200 + [[package]] 154 201 name = "displaydoc" 155 202 version = "0.2.5" 156 203 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 178 225 checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 179 226 180 227 [[package]] 228 + name = "equivalent" 229 + version = "1.0.2" 230 + source = "registry+https://github.com/rust-lang/crates.io-index" 231 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 232 + 233 + [[package]] 181 234 name = "errno" 182 235 version = "0.3.13" 183 236 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 198 251 version = "0.1.9" 199 252 source = "registry+https://github.com/rust-lang/crates.io-index" 200 253 checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 254 + 255 + [[package]] 256 + name = "flate2" 257 + version = "1.1.9" 258 + source = "registry+https://github.com/rust-lang/crates.io-index" 259 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 260 + dependencies = [ 261 + "crc32fast", 262 + "miniz_oxide", 263 + ] 201 264 202 265 [[package]] 203 266 name = "form_urlencoded" ··· 234 297 ] 235 298 236 299 [[package]] 300 + name = "hashbrown" 301 + version = "0.17.0" 302 + source = "registry+https://github.com/rust-lang/crates.io-index" 303 + checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" 304 + 305 + [[package]] 237 306 name = "heck" 238 307 version = "0.5.0" 239 308 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 356 425 ] 357 426 358 427 [[package]] 428 + name = "indexmap" 429 + version = "2.14.0" 430 + source = "registry+https://github.com/rust-lang/crates.io-index" 431 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" 432 + dependencies = [ 433 + "equivalent", 434 + "hashbrown", 435 + ] 436 + 437 + [[package]] 359 438 name = "is-docker" 360 439 version = "0.2.0" 361 440 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 454 533 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 455 534 456 535 [[package]] 536 + name = "memchr" 537 + version = "2.8.0" 538 + source = "registry+https://github.com/rust-lang/crates.io-index" 539 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 540 + 541 + [[package]] 542 + name = "miniz_oxide" 543 + version = "0.8.9" 544 + source = "registry+https://github.com/rust-lang/crates.io-index" 545 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 546 + dependencies = [ 547 + "adler2", 548 + "simd-adler32", 549 + ] 550 + 551 + [[package]] 457 552 name = "once_cell" 458 553 version = "1.21.3" 459 554 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 586 681 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 587 682 588 683 [[package]] 684 + name = "simd-adler32" 685 + version = "0.3.9" 686 + source = "registry+https://github.com/rust-lang/crates.io-index" 687 + checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" 688 + 689 + [[package]] 589 690 name = "smallvec" 590 691 version = "1.15.1" 591 692 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 684 785 "thiserror", 685 786 "url", 686 787 "xattr", 788 + "zip", 687 789 ] 688 790 689 791 [[package]] ··· 995 1097 "quote", 996 1098 "syn", 997 1099 ] 1100 + 1101 + [[package]] 1102 + name = "zip" 1103 + version = "2.4.2" 1104 + source = "registry+https://github.com/rust-lang/crates.io-index" 1105 + checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" 1106 + dependencies = [ 1107 + "arbitrary", 1108 + "crc32fast", 1109 + "crossbeam-utils", 1110 + "displaydoc", 1111 + "flate2", 1112 + "indexmap", 1113 + "memchr", 1114 + "thiserror", 1115 + "zopfli", 1116 + ] 1117 + 1118 + [[package]] 1119 + name = "zopfli" 1120 + version = "0.8.3" 1121 + source = "registry+https://github.com/rust-lang/crates.io-index" 1122 + checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" 1123 + dependencies = [ 1124 + "bumpalo", 1125 + "crc32fast", 1126 + "log", 1127 + "simd-adler32", 1128 + ]
+1
Cargo.toml
··· 25 25 open = "5" 26 26 itertools = "0" 27 27 git2 = { version = "0.20", default-features = false } 28 + zip = { version = "2", default-features = false, features = ["deflate"] } 28 29 29 30 [dev-dependencies] 30 31 tempfile = "3"
+22 -1
src/main.rs
··· 192 192 gitignore: bool, 193 193 }, 194 194 195 + /// Export the entire workspace (tasks, archive, attrs, backlinks, index, 196 + /// next, remotes) into a zip archive. Works for both file-backed and 197 + /// git-backed workspaces. 198 + Export { 199 + /// Output path. Defaults to ./tsk.zip. 200 + #[arg(short = 'o')] 201 + output: Option<PathBuf>, 202 + }, 203 + 195 204 /// Migrate a file-backed workspace to a git-backed one. The directory must 196 205 /// now be inside a git repository (run `git init` first if needed). All 197 206 /// task data is copied into refs/tsk/* and the on-disk files are removed. ··· 328 337 Commands::Clean => command_clean(dir), 329 338 Commands::Remote { action } => command_remote(dir, action), 330 339 Commands::GitSetup { gitignore } => command_git_setup(dir, gitignore), 340 + Commands::Export { output } => command_export(dir, output), 331 341 Commands::Migrate => command_migrate(dir), 332 342 Commands::Reopen { task_id } => command_reopen(dir, task_id), 333 343 }; ··· 634 644 Ok(()) 635 645 } 636 646 647 + fn command_export(dir: PathBuf, output: Option<PathBuf>) -> Result<()> { 648 + let workspace = Workspace::from_path(dir)?; 649 + let dest = output.unwrap_or_else(|| PathBuf::from("tsk.zip")); 650 + workspace.export_zip(&dest)?; 651 + eprintln!("Wrote {}", dest.display()); 652 + Ok(()) 653 + } 654 + 637 655 fn command_migrate(dir: PathBuf) -> Result<()> { 638 656 let workspace = Workspace::from_path(dir)?; 639 657 let git_dir = workspace.migrate_to_git()?; 640 - eprintln!("Migrated workspace to git refs (git dir: {})", git_dir.display()); 658 + eprintln!( 659 + "Migrated workspace to git refs (git dir: {})", 660 + git_dir.display() 661 + ); 641 662 Ok(()) 642 663 } 643 664
+71 -1
src/workspace.rs
··· 434 434 Ok(Some(task)) 435 435 } 436 436 437 + /// Write a zip archive containing every blob in the workspace. Layout in the 438 + /// zip mirrors the logical key namespace (`tasks/<id>`, `archive/<id>`, 439 + /// `attrs/<id>`, `backlinks/<id>`, `index`, `next`, `remotes`). 440 + pub fn export_zip(&self, dest: &std::path::Path) -> Result<()> { 441 + let file = std::fs::File::create(dest)?; 442 + let mut writer = zip::ZipWriter::new(file); 443 + let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default() 444 + .compression_method(zip::CompressionMethod::Deflated); 445 + 446 + let mut keys: Vec<String> = Vec::new(); 447 + for prefix in ["tasks", "archive", "attrs", "backlinks"] { 448 + keys.extend(self.store().list(prefix)?); 449 + } 450 + for top in ["index", "next", "remotes"] { 451 + if self.store().exists(top)? { 452 + keys.push(top.to_string()); 453 + } 454 + } 455 + keys.sort(); 456 + 457 + use std::io::Write as _; 458 + for key in keys { 459 + if let Some(data) = self.store().read(&key)? { 460 + writer 461 + .start_file(&key, opts) 462 + .map_err(|e| Error::Parse(format!("zip start_file: {e}")))?; 463 + writer.write_all(&data)?; 464 + } 465 + } 466 + writer 467 + .finish() 468 + .map_err(|e| Error::Parse(format!("zip finish: {e}")))?; 469 + Ok(()) 470 + } 471 + 437 472 /// Migrate a file-backed workspace to a git-backed one. Returns Err if the 438 473 /// workspace is already git-backed or if no enclosing git repo is found. 439 474 /// All blobs are copied into refs/tsk/* and the on-disk task data is then ··· 791 826 } 792 827 793 828 #[test] 829 + fn test_export_zip_both_backends() { 830 + let (_d, file, git) = setup_dual(); 831 + for ws in [&file, &git] { 832 + let t = ws.new_task("t1".into(), "b1".into()).unwrap(); 833 + let id = t.id; 834 + ws.push_task(t).unwrap(); 835 + ws.add_remote("up", "/p").unwrap(); 836 + 837 + let out = ws.path.join("export.zip"); 838 + ws.export_zip(&out).unwrap(); 839 + assert!(out.exists() && std::fs::metadata(&out).unwrap().len() > 0); 840 + 841 + let f = std::fs::File::open(&out).unwrap(); 842 + let mut zip = zip::ZipArchive::new(f).unwrap(); 843 + let names: std::collections::HashSet<String> = 844 + (0..zip.len()).map(|i| zip.by_index(i).unwrap().name().to_string()).collect(); 845 + assert!(names.contains(&format!("tasks/{}", id.0))); 846 + assert!(names.contains("index")); 847 + assert!(names.contains("next")); 848 + assert!(names.contains("remotes")); 849 + 850 + // Round-trip the task content. 851 + use std::io::Read as _; 852 + let mut entry = zip.by_name(&format!("tasks/{}", id.0)).unwrap(); 853 + let mut buf = String::new(); 854 + entry.read_to_string(&mut buf).unwrap(); 855 + assert!(buf.starts_with("t1")); 856 + assert!(buf.contains("b1")); 857 + } 858 + } 859 + 860 + #[test] 794 861 fn test_migrate_file_to_git() { 795 862 let dir = tempfile::tempdir().unwrap(); 796 863 let root = dir.path().to_path_buf(); ··· 847 914 let read = ws2.task(TaskIdentifier::Id(id1)).unwrap(); 848 915 assert_eq!(read.title, "Active"); 849 916 assert_eq!(read.attributes.get("k"), Some(&"v".to_string())); 850 - assert_eq!(backend::task_location(ws2.store(), id2).unwrap(), Some(Loc::Archived)); 917 + assert_eq!( 918 + backend::task_location(ws2.store(), id2).unwrap(), 919 + Some(Loc::Archived) 920 + ); 851 921 let bl = backend::read_backlinks(ws2.store(), id2).unwrap(); 852 922 assert!(bl.contains(&id1)); 853 923 assert_eq!(ws2.read_remotes().unwrap().len(), 1);