Skip to main content

semora_cli/commands/
docs.rs

1//! `ls`, `show`, `get`, `tag`, `rm`, `open`.
2
3use std::io::Write;
4use std::path::PathBuf;
5
6use anyhow::Context;
7use semora_proto::PatchDoc;
8
9use crate::client::{Client, ListQuery};
10use crate::{Ctx, checkout, docref, exit, output, project_link};
11
12pub async fn ls(
13    ctx: &Ctx,
14    query: Option<String>,
15    tag: Option<String>,
16    kind: Option<String>,
17    project: Option<String>,
18    limit: u32,
19) -> anyhow::Result<i32> {
20    let client = ctx.client()?;
21    let list = client
22        .list(&ListQuery {
23            q: query,
24            tag,
25            kind,
26            project: project.clone(),
27            limit: Some(limit),
28            ..Default::default()
29        })
30        .await
31        .map_err(|e| match &project {
32            Some(p) if exit::is_not_found(&e) => {
33                exit::not_found(format!("no project `{p}` on {}", client.origin()))
34            }
35            _ => e,
36        })?;
37    // An old server ignores `?project=` and lists everything; don't pass that off as the project.
38    if project.is_some() && list.docs.iter().any(|d| d.project.is_none()) {
39        anyhow::bail!(
40            "this server has no projects; it may be older than this CLI. Update the server."
41        );
42    }
43    if ctx.json {
44        output::json(&list);
45        return Ok(exit::OK);
46    }
47    if list.docs.is_empty() {
48        println!("No documents.");
49        return Ok(exit::OK);
50    }
51    for d in &list.docs {
52        let tags = if d.tags.is_empty() {
53            String::new()
54        } else {
55            format!("  [{}]", d.tags.join(", "))
56        };
57        let at = match (&d.project, &d.path) {
58            (Some(p), Some(path)) => format!("  {p}:{path}"),
59            _ => String::new(),
60        };
61        println!(
62            "{}  r{:<3} {:<6} {}  {}{}{}",
63            d.id,
64            d.rev,
65            d.kind.as_str(),
66            output::ts(d.updated_at),
67            d.title,
68            tags,
69            at
70        );
71    }
72    if list.next_cursor.is_some() {
73        println!("… more; raise --limit or narrow with -q/--tag");
74    }
75    Ok(exit::OK)
76}
77
78pub async fn show(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
79    let client = ctx.client()?;
80    let id = docref::resolve(&client, doc).await?;
81    let d = client.doc(&id).await?;
82    if ctx.json {
83        output::json(&d);
84        return Ok(exit::OK);
85    }
86    let s = &d.summary;
87    println!(
88        "{}  ({}, r{}, {} revisions kept)",
89        s.title,
90        s.kind.as_str(),
91        s.rev,
92        s.revision_count
93    );
94    let link = output::LINK;
95    anstream::println!("  url    {link}{}{link:#}", s.url);
96    println!("  ws     {}", d.ws_url);
97    println!("  entry  {}", d.entry_path);
98    if let (Some(p), Some(path)) = (&s.project, &s.path) {
99        println!("  proj   {p}");
100        println!("  path   {path}");
101        if let Some(u) = &s.path_url {
102            anstream::println!("  link   {link}{u}{link:#}");
103        }
104    }
105    if !s.tags.is_empty() {
106        println!("  tags   {}", s.tags.join(", "));
107    }
108    println!("files:");
109    for f in &d.files {
110        println!("  {:<40} {:>9}  {}", f.path, output::size(f.size), f.mime);
111    }
112    println!("revisions:");
113    for r in &d.revisions {
114        // The pusher's name, and the key's name when the server shows it (Editors and the owner).
115        let by = match (&r.pushed_by, r.created_by.as_deref()) {
116            (Some(p), Some(key)) => format!("  by {} ({key})", output::clean_name(&p.name)),
117            (Some(p), None) => format!("  by {}", output::clean_name(&p.name)),
118            (None, Some(key)) => format!("  by {key}"),
119            (None, None) => String::new(),
120        };
121        let msg = r
122            .message
123            .as_deref()
124            .map(|m| format!("  {m}"))
125            .unwrap_or_default();
126        let from = r
127            .origin
128            .as_deref()
129            .map(|o| format!("  from {o}"))
130            .unwrap_or_default();
131        println!(
132            "  r{:<4} {}{}{}{}",
133            r.rev,
134            output::ts(r.created_at),
135            msg,
136            from,
137            by
138        );
139    }
140    Ok(exit::OK)
141}
142
143pub async fn get(
144    ctx: &Ctx,
145    doc: &str,
146    path: Option<String>,
147    rev: Option<i64>,
148    out: Option<PathBuf>,
149) -> anyhow::Result<i32> {
150    let client = ctx.client()?;
151    let id = docref::resolve(&client, doc).await?;
152    let bytes = client.raw(&id, path.as_deref(), rev).await?;
153    match out {
154        Some(p) => {
155            std::fs::write(&p, &bytes).with_context(|| format!("writing {}", p.display()))?
156        }
157        None => {
158            let mut stdout = std::io::stdout().lock();
159            stdout.write_all(&bytes)?;
160            stdout.flush()?;
161        }
162    }
163    Ok(exit::OK)
164}
165
166/// `+a -b c` → add a and c, remove b.
167pub fn tag_changes(changes: &[String]) -> PatchDoc {
168    let mut p = PatchDoc::default();
169    for c in changes {
170        for part in c.split(',').map(str::trim).filter(|s| !s.is_empty()) {
171            if let Some(t) = part.strip_prefix('-') {
172                p.remove_tags.push(t.to_string());
173            } else {
174                p.add_tags.push(part.trim_start_matches('+').to_string());
175            }
176        }
177    }
178    p
179}
180
181pub async fn tag(ctx: &Ctx, doc: &str, changes: &[String]) -> anyhow::Result<i32> {
182    let client = ctx.client()?;
183    let id = docref::resolve(&client, doc).await?;
184    let s = client.patch(&id, &tag_changes(changes)).await?;
185    if ctx.json {
186        output::json(&s);
187    } else {
188        println!(
189            "{}: {}",
190            s.id,
191            if s.tags.is_empty() {
192                "(no tags)".to_string()
193            } else {
194                s.tags.join(", ")
195            }
196        );
197    }
198    Ok(exit::OK)
199}
200
201pub async fn rm(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
202    let client = ctx.client()?;
203    let id = docref::resolve(&client, doc).await?;
204    client.delete(&id).await?;
205    forget_here(&client, &ctx.settings.config_dir, &id).await;
206    if !ctx.json {
207        let done = output::out().done;
208        let days = semora_proto::DELETED_RETENTION_DAYS;
209        anstream::println!("{done}Deleted{done:#} {id} (purged after {days} days)");
210    }
211    Ok(exit::OK)
212}
213
214/// Drops the current folder's record of a document deleted from it, so the next push of its
215/// path makes it again instead of being refused as a push over someone else's delete.
216async fn forget_here(client: &Client, config_dir: &std::path::Path, id: &str) {
217    let Ok(cwd) = std::env::current_dir() else {
218        return;
219    };
220    let Ok(Some(file)) = project_link::find(&cwd) else {
221        return;
222    };
223    // A folder for another server holds no record of this server's documents.
224    let Ok(link) = project_link::resolve(client, config_dir, file).await else {
225        return;
226    };
227    let held = checkout::load(&link).is_ok_and(|r| r.values().any(|rec| rec.id == id));
228    if !held {
229        return;
230    }
231    if let Err(e) = checkout::update(&link, |r| r.retain(|_, rec| rec.id != id)) {
232        output::warn(format!("could not update this folder's record: {e:#}"));
233    }
234}
235
236pub async fn open(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
237    let client = ctx.client()?;
238    let id = docref::resolve(&client, doc).await?;
239    let d = client.doc(&id).await?;
240    let url = web_link(&d.summary.url)?;
241    anstream::println!("{LINK}{url}{LINK:#}", LINK = output::LINK);
242    if ctx.settings.open_browser
243        && let Err(e) = open::that(&url)
244    {
245        output::warn(format!("could not open a browser: {e}"));
246    }
247    Ok(exit::OK)
248}
249
250/// The server's page address, only as an http(s) link. The system would run a file path or
251/// hand another scheme to its app, so a hostile server could start a program.
252pub(crate) fn web_link(url: &str) -> anyhow::Result<String> {
253    match url::Url::parse(url) {
254        Ok(u) if matches!(u.scheme(), "http" | "https") => Ok(u.into()),
255        _ => anyhow::bail!("the server's page address is not a web link: {url:?}"),
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::tag_changes;
262
263    #[test]
264    fn parses_tag_changes() {
265        let p = tag_changes(&["+a".into(), "-b".into(), "c,d".into()]);
266        assert_eq!(p.add_tags, vec!["a", "c", "d"]);
267        assert_eq!(p.remove_tags, vec!["b"]);
268    }
269}