1use 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 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 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
166pub 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
214async 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 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
250pub(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}