use std::io::Write;
use std::path::PathBuf;
use anyhow::Context;
use semora_proto::PatchDoc;
use crate::client::{Client, ListQuery};
use crate::{Ctx, checkout, docref, exit, output, project_link};
pub async fn ls(
ctx: &Ctx,
query: Option<String>,
tag: Option<String>,
kind: Option<String>,
project: Option<String>,
limit: u32,
) -> anyhow::Result<i32> {
let client = ctx.client()?;
let list = client
.list(&ListQuery {
q: query,
tag,
kind,
project: project.clone(),
limit: Some(limit),
..Default::default()
})
.await
.map_err(|e| match &project {
Some(p) if exit::is_not_found(&e) => {
exit::not_found(format!("no project `{p}` on {}", client.origin()))
}
_ => e,
})?;
if project.is_some() && list.docs.iter().any(|d| d.project.is_none()) {
anyhow::bail!(
"this server has no projects; it may be older than this CLI. Update the server."
);
}
if ctx.json {
output::json(&list);
return Ok(exit::OK);
}
if list.docs.is_empty() {
println!("No documents.");
return Ok(exit::OK);
}
for d in &list.docs {
let tags = if d.tags.is_empty() {
String::new()
} else {
format!(" [{}]", d.tags.join(", "))
};
let at = match (&d.project, &d.path) {
(Some(p), Some(path)) => format!(" {p}:{path}"),
_ => String::new(),
};
println!(
"{} r{:<3} {:<6} {} {}{}{}",
d.id,
d.rev,
d.kind.as_str(),
output::ts(d.updated_at),
d.title,
tags,
at
);
}
if list.next_cursor.is_some() {
println!("… more; raise --limit or narrow with -q/--tag");
}
Ok(exit::OK)
}
pub async fn show(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
let client = ctx.client()?;
let id = docref::resolve(&client, doc).await?;
let d = client.doc(&id).await?;
if ctx.json {
output::json(&d);
return Ok(exit::OK);
}
let s = &d.summary;
println!(
"{} ({}, r{}, {} revisions kept)",
s.title,
s.kind.as_str(),
s.rev,
s.revision_count
);
let link = output::LINK;
anstream::println!(" url {link}{}{link:#}", s.url);
println!(" ws {}", d.ws_url);
println!(" entry {}", d.entry_path);
if let (Some(p), Some(path)) = (&s.project, &s.path) {
println!(" proj {p}");
println!(" path {path}");
if let Some(u) = &s.path_url {
anstream::println!(" link {link}{u}{link:#}");
}
}
if !s.tags.is_empty() {
println!(" tags {}", s.tags.join(", "));
}
println!("files:");
for f in &d.files {
println!(" {:<40} {:>9} {}", f.path, output::size(f.size), f.mime);
}
println!("revisions:");
for r in &d.revisions {
let by = match (&r.pushed_by, r.created_by.as_deref()) {
(Some(p), Some(key)) => format!(" by {} ({key})", output::clean_name(&p.name)),
(Some(p), None) => format!(" by {}", output::clean_name(&p.name)),
(None, Some(key)) => format!(" by {key}"),
(None, None) => String::new(),
};
let msg = r
.message
.as_deref()
.map(|m| format!(" {m}"))
.unwrap_or_default();
let from = r
.origin
.as_deref()
.map(|o| format!(" from {o}"))
.unwrap_or_default();
println!(
" r{:<4} {}{}{}{}",
r.rev,
output::ts(r.created_at),
msg,
from,
by
);
}
Ok(exit::OK)
}
pub async fn get(
ctx: &Ctx,
doc: &str,
path: Option<String>,
rev: Option<i64>,
out: Option<PathBuf>,
) -> anyhow::Result<i32> {
let client = ctx.client()?;
let id = docref::resolve(&client, doc).await?;
let bytes = client.raw(&id, path.as_deref(), rev).await?;
match out {
Some(p) => {
std::fs::write(&p, &bytes).with_context(|| format!("writing {}", p.display()))?
}
None => {
let mut stdout = std::io::stdout().lock();
stdout.write_all(&bytes)?;
stdout.flush()?;
}
}
Ok(exit::OK)
}
pub fn tag_changes(changes: &[String]) -> PatchDoc {
let mut p = PatchDoc::default();
for c in changes {
for part in c.split(',').map(str::trim).filter(|s| !s.is_empty()) {
if let Some(t) = part.strip_prefix('-') {
p.remove_tags.push(t.to_string());
} else {
p.add_tags.push(part.trim_start_matches('+').to_string());
}
}
}
p
}
pub async fn tag(ctx: &Ctx, doc: &str, changes: &[String]) -> anyhow::Result<i32> {
let client = ctx.client()?;
let id = docref::resolve(&client, doc).await?;
let s = client.patch(&id, &tag_changes(changes)).await?;
if ctx.json {
output::json(&s);
} else {
println!(
"{}: {}",
s.id,
if s.tags.is_empty() {
"(no tags)".to_string()
} else {
s.tags.join(", ")
}
);
}
Ok(exit::OK)
}
pub async fn rm(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
let client = ctx.client()?;
let id = docref::resolve(&client, doc).await?;
client.delete(&id).await?;
forget_here(&client, &ctx.settings.config_dir, &id).await;
if !ctx.json {
let done = output::out().done;
let days = semora_proto::DELETED_RETENTION_DAYS;
anstream::println!("{done}Deleted{done:#} {id} (purged after {days} days)");
}
Ok(exit::OK)
}
async fn forget_here(client: &Client, config_dir: &std::path::Path, id: &str) {
let Ok(cwd) = std::env::current_dir() else {
return;
};
let Ok(Some(file)) = project_link::find(&cwd) else {
return;
};
let Ok(link) = project_link::resolve(client, config_dir, file).await else {
return;
};
let held = checkout::load(&link).is_ok_and(|r| r.values().any(|rec| rec.id == id));
if !held {
return;
}
if let Err(e) = checkout::update(&link, |r| r.retain(|_, rec| rec.id != id)) {
output::warn(format!("could not update this folder's record: {e:#}"));
}
}
pub async fn open(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
let client = ctx.client()?;
let id = docref::resolve(&client, doc).await?;
let d = client.doc(&id).await?;
let url = web_link(&d.summary.url)?;
anstream::println!("{LINK}{url}{LINK:#}", LINK = output::LINK);
if ctx.settings.open_browser
&& let Err(e) = open::that(&url)
{
output::warn(format!("could not open a browser: {e}"));
}
Ok(exit::OK)
}
pub(crate) fn web_link(url: &str) -> anyhow::Result<String> {
match url::Url::parse(url) {
Ok(u) if matches!(u.scheme(), "http" | "https") => Ok(u.into()),
_ => anyhow::bail!("the server's page address is not a web link: {url:?}"),
}
}
#[cfg(test)]
mod tests {
use super::tag_changes;
#[test]
fn parses_tag_changes() {
let p = tag_changes(&["+a".into(), "-b".into(), "c,d".into()]);
assert_eq!(p.add_tags, vec!["a", "c", "d"]);
assert_eq!(p.remove_tags, vec!["b"]);
}
}