use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, bail};
use semora_proto::PushResponse;
use serde_json::{Map, Value};
use crate::args::PushArgs;
use crate::bundle;
use crate::checkout::{self, Record};
use crate::client::{Client, PushQuery, encode_path};
use crate::project_link::{self, Link};
use crate::remember::{self, Memo};
use crate::{Ctx, docref, exit, output};
pub struct Payload {
pub body: Vec<u8>,
pub content_type: &'static str,
pub filename: Option<String>,
pub hashes: BTreeMap<String, String>,
}
pub fn payload(path: &Path) -> anyhow::Result<Payload> {
let meta =
std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
if meta.is_dir() {
let packed = bundle::pack_dir(path)?;
for (p, why) in &packed.skipped {
output::warn(format!("skipped {p} ({why})"));
}
if packed.files.is_empty() {
bail!("{} has no files Semora can serve", path.display());
}
if !packed.files.iter().any(|f| bundle::is_page(f)) {
bail!("{} has no Markdown or HTML page", path.display());
}
return Ok(Payload {
body: packed.tar,
content_type: "application/x-tar",
filename: None,
hashes: packed.hashes,
});
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let lower = name.to_ascii_lowercase();
let content_type = if lower.ends_with(".md") || lower.ends_with(".markdown") {
"text/markdown; charset=utf-8"
} else if lower.ends_with(".html") || lower.ends_with(".htm") {
"text/html; charset=utf-8"
} else {
bail!("{name}: push a .md or .html file, or a folder");
};
let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
if content_type.starts_with("text/markdown") {
let links = bundle::local_page_links(&String::from_utf8_lossy(&body));
if !links.is_empty() {
output::warn(format!(
"{name} links to local pages ({}); push the folder to publish them together as a bundle",
links.join(", ")
));
}
}
Ok(Payload {
hashes: [(String::new(), checkout::sha256(&body))].into(),
body,
content_type,
filename: Some(name),
})
}
pub fn parse_meta(pairs: &[String]) -> anyhow::Result<Option<String>> {
if pairs.is_empty() {
return Ok(None);
}
let mut m = Map::new();
for p in pairs {
let (k, v) = p
.split_once('=')
.with_context(|| format!("--meta `{p}` must be key=value"))?;
let value = match serde_json::from_str::<Value>(v) {
Ok(j @ (Value::Number(_) | Value::Bool(_) | Value::Null)) => j,
_ => Value::String(v.to_string()),
};
m.insert(k.trim().to_string(), value);
}
Ok(Some(Value::Object(m).to_string()))
}
pub fn tags(args_tags: &[String], defaults: &[String]) -> Option<String> {
let all: Vec<String> = args_tags
.iter()
.flat_map(|t| t.split(','))
.chain(defaults.iter().map(String::as_str))
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect();
(!all.is_empty()).then(|| all.join(","))
}
pub fn note_outside_project(path: &Path, id: Option<&str>, new: bool) {
if id.is_none() && !new {
return;
}
let Ok(Some(link)) = project_link::find(path) else {
return;
};
if id.is_some() {
eprintln!(
"note: --id pushes to that document directly, not into project {}.",
link.slug
);
} else {
eprintln!(
"note: --new makes a document outside project {}.",
link.slug
);
}
}
pub fn hint_outside_project(err: anyhow::Error, flags: &str) -> anyhow::Error {
if err.downcast_ref::<project_link::OtherServer>().is_some() {
anyhow::anyhow!("{err} Or use {flags} to push outside the project.")
} else {
err
}
}
pub fn check_filed(resp: &PushResponse, slug: &str) -> anyhow::Result<()> {
if resp.project.is_none() {
bail!(
"the server did not file this under project `{slug}`; it may be older than this CLI. Update the server."
);
}
Ok(())
}
fn stale_push(
client: &Client,
link: &Link,
key: &str,
path: &Path,
bundle: bool,
err: &anyhow::Error,
) -> anyhow::Error {
let shown = path.display();
let url = client.url(&format!("/p/{}/{}", link.slug, encode_path(key)));
let get = if bundle {
format!("`semora show {url}` lists its files, `semora get {url} FILE` prints one")
} else {
format!("`semora get {url}` prints it")
};
exit::stale(format!(
"{}. It changed on the server after this folder last pulled or pushed it. Merge the server's copy into {shown} ({get}) and push again with --force, or run `semora pull --force {shown}` to take the server's copy and drop your changes.",
exit::message_of(err)
))
}
#[allow(clippy::too_many_arguments)]
async fn push_keyed(
ctx: &Ctx,
client: &Client,
path: &Path,
link: &Link,
key: &str,
p: Payload,
mut q: PushQuery,
force: bool,
) -> anyhow::Result<PushResponse> {
let server = client.origin();
q.project = Some(link.slug.clone());
q.path = Some(key.to_string());
q.origin = Some(project_link::origin_for(path));
q.adopt = remember::load(&ctx.settings.config_dir, path)
.filter(|m| m.server == server)
.map(|m| m.id);
let known = if force {
None
} else {
checkout::load(link)?.remove(key)
};
let if_match = known.map(|r| {
q.base = Some(r.id);
r.rev
});
let files: BTreeMap<String, String> = p
.hashes
.iter()
.map(|(rel, hash)| {
let local = if rel.is_empty() {
key.to_string()
} else {
format!("{key}/{rel}")
};
(local, hash.clone())
})
.collect();
let bundle = p.filename.is_none();
let resp = match client
.push(None, p.body, p.content_type, &q, if_match)
.await
{
Ok(r) => r,
Err(e) if exit::is_stale(&e) => {
return Err(stale_push(client, link, key, path, bundle, &e));
}
Err(e) if exit::is_not_found(&e) => {
return Err(exit::not_found(format!(
"project `{}` was not found on {server}. Run `semora project init` to create it again, or you may no longer have access to this project.",
link.slug
)));
}
Err(e) if exit::is_conflict(&e) => bail!(
"the server could not file this path in project `{}`: another push got there first, or you may no longer have access to this project. Try again.",
link.slug
),
Err(e) => return Err(e),
};
check_filed(&resp, &link.slug)?;
let record = Record {
id: resp.id.clone(),
rev: resp.rev,
kind: resp.kind,
files,
};
if let Err(e) = checkout::update(link, |r| r.insert(key.to_string(), record)) {
output::warn(format!(
"could not record the pushed revision, so the next push of {key} can't check for newer ones: {e:#}"
));
}
Ok(resp)
}
pub async fn push_path(
ctx: &Ctx,
client: &Client,
path: &Path,
explicit_id: Option<&str>,
force_new: bool,
force: bool,
query: &PushQuery,
) -> anyhow::Result<PushResponse> {
let keyed = if explicit_id.is_none() && !force_new {
match project_link::find(path)? {
Some(link) => {
link.check_server(client)?;
let key = link.relative_path(path)?;
Some((link, key))
}
None => None,
}
} else {
None
};
let p = payload(path)?;
let mut q = query.clone();
q.filename = p.filename.clone();
if let Some((link, key)) = &keyed {
return push_keyed(ctx, client, path, link, key, p, q, force).await;
}
let server = client.origin();
let config_dir = &ctx.settings.config_dir;
let remembered = if force_new || explicit_id.is_some() {
None
} else {
remember::load(config_dir, path)
.filter(|m| m.server == server)
.map(|m| m.id)
};
let explicit = match explicit_id {
Some(s) => Some(docref::resolve(client, s).await?),
None => None,
};
let target = explicit.clone().or(remembered.clone());
let resp = match target {
Some(id) => match client
.push(Some(&id), p.body.clone(), p.content_type, &q, None)
.await
{
Ok(r) => r,
Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
output::warn(format!(
"the remembered document {id} is gone; pushing a new document"
));
client.push(None, p.body, p.content_type, &q, None).await?
}
Err(e) => return Err(e),
},
None => client.push(None, p.body, p.content_type, &q, None).await?,
};
let memo = Memo {
id: resp.id.clone(),
server,
};
if let Err(e) = remember::save(config_dir, path, &memo) {
output::warn(format!("could not remember the document id: {e:#}"));
}
Ok(resp)
}
fn note_unrecorded_message(r: &PushResponse, message: Option<&str>) {
if r.unchanged && message.is_some_and(|m| !m.trim().is_empty()) {
eprintln!(
"note: the content was unchanged, so no revision was made and the message was not recorded."
);
}
}
pub fn print_push(ctx: &Ctx, r: &PushResponse) {
for b in &r.broken_links {
output::warn(format!("broken link in {}: {}", b.from, b.href));
}
for w in &r.warnings {
output::warn(w);
}
if ctx.json {
output::json(r);
return;
}
let files = if r.files == 1 {
"1 file".to_string()
} else {
format!("{} files", r.files)
};
if r.unchanged {
println!(
"Nothing changed: \"{}\" stays at r{} · {} · {}",
r.title, r.rev, r.kind, files
);
} else {
println!(
"Pushed \"{}\" · r{} · {} · {}",
r.title, r.rev, r.kind, files
);
}
if let Some(u) = &r.path_url {
println!(" path {u}");
}
println!(" url {}", r.url);
println!(" ws {}", r.ws_url);
println!(" wait semora wait {}", r.id);
}
pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
let client = ctx.client()?;
note_outside_project(&args.path, args.id.as_deref(), args.new);
let query = PushQuery {
title: args.title.clone(),
tags: tags(&args.tags, &ctx.settings.default_tags),
message: args.message.clone(),
entry: args.entry.clone(),
meta: parse_meta(&args.meta)?,
..Default::default()
};
let resp = push_path(
ctx,
&client,
&args.path,
args.id.as_deref(),
args.new,
args.force,
&query,
)
.await
.map_err(|e| hint_outside_project(e, "--id or --new"))?;
note_unrecorded_message(&resp, args.message.as_deref());
if args.wait {
if !ctx.json {
print_push(ctx, &resp);
} else {
for b in &resp.broken_links {
output::warn(format!("broken link in {}: {}", b.from, b.href));
}
}
return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
}
print_push(ctx, &resp);
Ok(exit::OK)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meta_values_keep_scalar_types() {
let m = parse_meta(&[
"a=1".into(),
"b=true".into(),
"c=hello world".into(),
"d=x=y".into(),
])
.unwrap()
.unwrap();
let v: Value = serde_json::from_str(&m).unwrap();
assert_eq!(
v,
serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
);
assert!(parse_meta(&["novalue".into()]).is_err());
assert_eq!(parse_meta(&[]).unwrap(), None);
}
#[test]
fn an_old_server_without_projects_is_reported() {
let mut r = PushResponse {
id: "k3F9xQ2mLp".into(),
rev: 1,
kind: semora_proto::DocKind::Md,
url: "u".into(),
ws_url: "w".into(),
files: 1,
title: "T".into(),
unchanged: false,
broken_links: vec![],
warnings: vec![],
project: None,
path: None,
path_url: None,
};
let err = check_filed(&r, "semora").unwrap_err().to_string();
assert!(
err.contains("older than this CLI") && err.contains("semora"),
"{err}"
);
r.project = Some("semora".into());
assert!(check_filed(&r, "semora").is_ok());
}
#[test]
fn tags_merge_flags_and_defaults() {
assert_eq!(
tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
Some("a,b,c,proj")
);
assert_eq!(tags(&[], &[]), None);
}
}