fur_cli/commands/
export.rs1use std::path::Path;
2
3use clap::Parser;
4use colored::*;
5use serde_json::Value;
6
7use crate::schema::bridge::{document_from_thread, linked_sources, write_conversation_folder};
8
9#[derive(Parser, Debug)]
11pub struct ExportArgs {
12 #[arg(short, long)]
14 pub id: Option<String>,
15
16 #[arg(long)]
18 pub all: bool,
19
20 #[arg(long)]
22 pub force: bool,
23}
24
25pub fn run_export(args: ExportArgs) {
31 let fur_dir = Path::new(".fur");
32 let index_path = fur_dir.join("index.json");
33
34 if !index_path.exists() {
35 eprintln!("šØ .fur/ not found. Run `fur new` first.");
36 return;
37 }
38
39 let Some(content) = crate::security::io::read_text_file(&index_path) else {
40 return;
41 };
42
43 let index: Value = match serde_json::from_str(&content) {
44 Ok(v) => v,
45 Err(e) => return eprintln!("ā Invalid index.json: {}", e),
46 };
47
48 let threads: Vec<String> = index["threads"]
49 .as_array()
50 .map(|arr| {
51 arr.iter()
52 .filter_map(|v| v.as_str().map(|s| s.to_string()))
53 .collect()
54 })
55 .unwrap_or_default();
56
57 let targets = match select_targets(&index, &threads, &args) {
58 Some(t) => t,
59 None => return,
60 };
61
62 let project_root = Path::new(".");
63 let mut exported = 0usize;
64
65 for tid in &targets {
66 match export_one(fur_dir, project_root, tid, args.force) {
67 Ok(folder) => {
68 println!("š {}", folder.display().to_string().green());
69 exported += 1;
70 }
71 Err(e) => eprintln!("ā {} ā {}", &short(tid).bright_black(), e),
72 }
73 }
74
75 println!(
76 "\nāļø Exported {} of {} conversation(s).",
77 exported,
78 targets.len()
79 );
80
81 report_orphans(project_root);
82}
83
84fn report_orphans(project_root: &Path) {
87 let orphans = crate::schema::bridge::find_orphans(project_root);
88
89 if orphans.is_empty() {
90 return;
91 }
92
93 println!(
94 "\n{}",
95 "Duplicate long-form files in chats/ root".bright_yellow().bold()
96 );
97
98 for p in &orphans {
99 println!(" ⢠{}", p.display());
100 }
101
102 println!(
103 "{}",
104 " These are already inside their conversation folder and can be deleted."
105 .bright_black()
106 );
107}
108
109fn export_one(
110 fur_dir: &Path,
111 project_root: &Path,
112 tid: &str,
113 force: bool,
114) -> Result<std::path::PathBuf, String> {
115 let doc = document_from_thread(fur_dir, tid)?;
116 let linked = linked_sources(fur_dir, tid)?;
117
118 write_conversation_folder(project_root, &doc, &linked, force)
119}
120
121fn select_targets(index: &Value, threads: &[String], args: &ExportArgs) -> Option<Vec<String>> {
124 if args.all {
125 if threads.is_empty() {
126 eprintln!("ā ļø No conversations to export.");
127 return None;
128 }
129 return Some(threads.to_vec());
130 }
131
132 if let Some(prefix) = &args.id {
133 let matches: Vec<&String> = threads.iter().filter(|t| t.starts_with(prefix)).collect();
134
135 return match matches.as_slice() {
136 [] => {
137 eprintln!("ā No conversation matches '{}'", prefix);
138 None
139 }
140 [single] => Some(vec![(*single).clone()]),
141 _ => {
142 eprintln!("ā Ambiguous prefix '{}': {:?}", prefix, matches);
143 None
144 }
145 };
146 }
147
148 match index["active_thread"].as_str() {
149 Some(active) if !active.is_empty() => Some(vec![active.to_string()]),
150 _ => {
151 eprintln!("ā No active conversation. Use --id or --all.");
152 None
153 }
154 }
155}
156
157fn short(id: &str) -> &str {
158 if id.len() >= 8 {
159 &id[..8]
160 } else {
161 id
162 }
163}