Skip to main content

onenote_cli/
output.rs

1use crate::{cli::Format, error::Error};
2use serde_json::Value;
3use std::io::{self, IsTerminal, Write};
4
5pub fn structured(format: Format) -> bool {
6    format == Format::Json || (format == Format::Auto && !io::stdout().is_terminal())
7}
8// Honor the last output selection even when clap cannot parse the command.
9pub fn format_from_args(args: &[String]) -> Format {
10    let mut format = Format::Auto;
11    let mut index = 1;
12    while index < args.len() {
13        let arg = &args[index];
14        if arg == "--" {
15            break;
16        }
17        let value = if arg == "--output" || arg == "-o" {
18            index += 1;
19            args.get(index).map(String::as_str)
20        } else {
21            arg.strip_prefix("--output=")
22                .or_else(|| arg.strip_prefix("-o="))
23                .or_else(|| arg.strip_prefix("-o").filter(|s| !s.is_empty()))
24        };
25        if let Some(value) = value {
26            format = match value {
27                "json" => Format::Json,
28                "text" => Format::Text,
29                "auto" => Format::Auto,
30                _ => format,
31            };
32        }
33        index += 1;
34    }
35    format
36}
37pub fn clean(text: &str) -> String {
38    text.chars()
39        .filter(|c| {
40            (!c.is_control() || *c == '\n' || *c == '\t')
41                && !matches!(*c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
42        })
43        .collect()
44}
45pub fn render(value: &Value, color: bool) -> String {
46    let title = |s: &str| {
47        if color {
48            format!("\x1b[1;35m{}\x1b[0m", clean(s))
49        } else {
50            clean(s)
51        }
52    };
53    match value["view"].as_str() {
54        Some("config_path") => return format!("{}\n", clean(value["path"].as_str().unwrap_or(""))),
55        Some("profiles") => {
56            let mut text = format!("{}\n", title("OneNote · profiles"));
57            let profiles = value["profiles"].as_array().cloned().unwrap_or_default();
58            if profiles.is_empty() {
59                text.push_str("\n  No saved profiles. Start with onenote init.\n");
60            }
61            for p in profiles {
62                text.push_str(&format!(
63                    "\n  {} {}{}\n    {} · {}\n",
64                    if p["active"] == true { "●" } else { "○" },
65                    title(p["name"].as_str().unwrap_or("")),
66                    if p["selected"] == true && p["active"] != true {
67                        " (override)"
68                    } else {
69                        ""
70                    },
71                    clean(p["target"].as_str().unwrap_or("")),
72                    if p["read_only"] == true {
73                        "read-only"
74                    } else {
75                        "read and write"
76                    }
77                ));
78            }
79            text.push_str("\n  ● saved default · Select with onenote profile use NAME\n");
80            return text;
81        }
82        Some("init" | "config") => {
83            let name = value["profile"].as_str().unwrap_or("local (implicit)");
84            let connection = &value["connection"];
85            let mut text = format!(
86                "{}\n\n  Connection  {}\n  Access      {}\n  Config      {}\n",
87                title(&format!("OneNote · {name}")),
88                clean(value["target"].as_str().unwrap_or("")),
89                if connection["read_only"] == true {
90                    "read-only"
91                } else {
92                    "read and write"
93                },
94                clean(value["path"].as_str().unwrap_or(""))
95            );
96            if let Some(key) = connection["identity_file"].as_str() {
97                text.push_str(&format!("  SSH key     {}\n", clean(key)));
98            }
99            if let Some(port) = connection["port"].as_u64() {
100                text.push_str(&format!("  SSH port    {port}\n"));
101            }
102            if value["view"] == "init" {
103                text.push_str(&format!(
104                    "\n  {}\n\n  Next: onenote {}\n",
105                    clean(value["message"].as_str().unwrap_or("")),
106                    if value["verified"] == true {
107                        "notebooks list"
108                    } else {
109                        "doctor"
110                    }
111                ));
112            } else {
113                text.push_str(&format!(
114                    "  Selection   {}\n",
115                    match value["source"].as_str() {
116                        Some("active_profile") => "saved default",
117                        Some("override") => "--profile or ONENOTE_PROFILE",
118                        _ => "local desktop fallback",
119                    }
120                ));
121            }
122            return text;
123        }
124        Some("profile_change") => {
125            let mut text = format!("{}\n", clean(value["message"].as_str().unwrap_or("")));
126            if let Some(name) = value["override"].as_str() {
127                text.push_str(&format!("Profile override '{name}' still takes precedence; clear ONENOTE_PROFILE or --profile to use the saved default.\n"));
128            }
129            return text;
130        }
131        _ => (),
132    }
133    if value["dry_run"] == true {
134        return format!(
135            "{}\nProfile: {}\nConnection: {}{}\nTarget: {}\n{}\n{}\n\nNo desktop connection or write was made.\n",
136            title(&format!(
137                "Preview · pages {}",
138                value["operation"].as_str().unwrap_or("write")
139            )),
140            clean(value["profile"].as_str().unwrap_or("local (implicit)")),
141            clean(
142                value["connection"]["host"]
143                    .as_str()
144                    .unwrap_or("local Windows desktop")
145            ),
146            if value["connection"]["read_only"] == true {
147                " · read-only"
148            } else {
149                ""
150            },
151            clean(value["target"].as_str().unwrap_or("")),
152            value["title"]
153                .as_str()
154                .map(|s| format!("Title: {}\n", clean(s)))
155                .unwrap_or_default(),
156            clean(value["text"].as_str().unwrap_or(""))
157        );
158    }
159    if let Some(items) = value["items"].as_array() {
160        let mut text = format!(
161            "{}\n",
162            title(&format!("OneNote · {} of {}", items.len(), value["total"]))
163        );
164        if items.is_empty() {
165            text.push_str("\n  No matching items.\n");
166        }
167        for item in items {
168            text.push_str(&format!(
169                "\n  {}\n",
170                title(item["name"].as_str().unwrap_or("Item"))
171            ));
172            for field in ["path", "id", "last_modified", "locked", "kind", "parent_id"] {
173                if let Some(v) = item.get(field).filter(|v| !v.is_null()) {
174                    let v = v
175                        .as_str()
176                        .map(str::to_owned)
177                        .unwrap_or_else(|| v.to_string());
178                    text.push_str(&format!("  {field}: {}\n", clean(&v)));
179                }
180            }
181        }
182        if !value["next_offset"].is_null() {
183            text.push_str(&format!(
184                "\n  Continue with the same command and --offset {}\n",
185                value["next_offset"]
186            ));
187        }
188        if value["indexing_pending"] == true {
189            text.push_str(&format!("\n  {} unindexed pages omitted. Search results may be incomplete until Windows Search finishes indexing.\n", value["unindexed_count"]));
190        }
191        if value["search_mode"] == "scan" {
192            text.push_str(&format!(
193                "\n  Text scan · {} pages read; {} locked sections and {} pages skipped.\n",
194                value["scanned_pages"],
195                value["skipped_locked_sections"],
196                value["skipped_pages"].as_array().map_or(0, Vec::len)
197            ));
198            if value["incomplete"] == true {
199                text.push_str("  Results are incomplete for this scope.\n");
200            }
201            if !value["next_scan_offset"].is_null() {
202                text.push_str(&format!("  After paging through these matches, continue with --scan-offset {} and --offset 0.\n", value["next_scan_offset"]));
203            }
204        }
205        return text;
206    }
207    if let Some(markdown) = value["markdown"].as_str() {
208        let mut text = format!(
209            "{}\nid: {}\n\n{}\n",
210            title(value["name"].as_str().unwrap_or("Untitled")),
211            clean(value["id"].as_str().unwrap_or("")),
212            clean(markdown)
213        );
214        if value["verified"] == true {
215            text.push_str("\nWrite verified in OneNote desktop.\n");
216        }
217        if value["has_images"] == true || value["has_attachments"] == true {
218            text.push_str("\nThis page also contains images or attachments; these are not included in the text.\n");
219        }
220        if let Some(xml) = value["xml"].as_str() {
221            text.push_str(&format!("\n{}\n{}\n", title("Page XML"), clean(xml)));
222        }
223        return text;
224    }
225    if value.get("verified").is_some() {
226        return format!(
227            "{}\n{}\n",
228            title(if value["available"] == true {
229                "OneNote desktop is ready"
230            } else {
231                "OneNote desktop check"
232            }),
233            clean(value["message"].as_str().unwrap_or(""))
234        );
235    }
236    clean(&serde_json::to_string_pretty(value).unwrap_or_default()) + "\n"
237}
238pub fn print(value: &Value, format: Format, no_color: bool) -> Result<(), Error> {
239    let text = if structured(format) {
240        serde_json::to_string_pretty(value).map_err(|e| Error::desktop(e.to_string()))? + "\n"
241    } else {
242        render(
243            value,
244            !no_color && std::env::var_os("NO_COLOR").is_none() && io::stdout().is_terminal(),
245        )
246    };
247    match io::stdout().lock().write_all(text.as_bytes()) {
248        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
249        result => result.map_err(Error::from),
250    }
251}
252pub fn error(error: &Error, format: Format) {
253    let text = if structured(format) {
254        let mut value = serde_json::json!({"error":{"kind":error.kind,"message":error.message,"exit_code":error.code(),"retryable":false}});
255        if let Some(id) = &error.page_id {
256            value["error"]["page_id"] = Value::String(id.clone());
257        }
258        value.to_string()
259    } else {
260        format!("error: {}", clean(&error.message))
261    };
262    let _ = writeln!(io::stderr().lock(), "{text}");
263}