1use std::path::Path;
4
5use anyhow::{Context, bail};
6use semora_proto::PushResponse;
7use serde_json::{Map, Value};
8
9use crate::args::PushArgs;
10use crate::bundle;
11use crate::client::{Client, PushQuery};
12use crate::remember::{self, Memo};
13use crate::{Ctx, docref, exit, output};
14
15pub struct Payload {
17 pub body: Vec<u8>,
18 pub content_type: &'static str,
19 pub filename: Option<String>,
20}
21
22pub fn payload(path: &Path) -> anyhow::Result<Payload> {
23 let meta =
24 std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
25 if meta.is_dir() {
26 let packed = bundle::pack_dir(path)?;
27 for (p, why) in &packed.skipped {
28 output::warn(format!("skipped {p} ({why})"));
29 }
30 if packed.files.is_empty() {
31 bail!("{} has no files Semora can serve", path.display());
32 }
33 if !packed.files.iter().any(|f| bundle::is_page(f)) {
34 bail!("{} has no Markdown or HTML page", path.display());
35 }
36 return Ok(Payload {
37 body: packed.tar,
38 content_type: "application/x-tar",
39 filename: None,
40 });
41 }
42 let name = path
43 .file_name()
44 .map(|n| n.to_string_lossy().into_owned())
45 .unwrap_or_default();
46 let lower = name.to_ascii_lowercase();
47 let content_type = if lower.ends_with(".md") || lower.ends_with(".markdown") {
48 "text/markdown; charset=utf-8"
49 } else if lower.ends_with(".html") || lower.ends_with(".htm") {
50 "text/html; charset=utf-8"
51 } else {
52 bail!("{name}: push a .md or .html file, or a folder");
53 };
54 let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
55 if content_type.starts_with("text/markdown") {
56 let links = bundle::local_page_links(&String::from_utf8_lossy(&body));
57 if !links.is_empty() {
58 output::warn(format!(
59 "{name} links to local pages ({}); push the folder to publish them together as a bundle",
60 links.join(", ")
61 ));
62 }
63 }
64 Ok(Payload {
65 body,
66 content_type,
67 filename: Some(name),
68 })
69}
70
71pub fn parse_meta(pairs: &[String]) -> anyhow::Result<Option<String>> {
73 if pairs.is_empty() {
74 return Ok(None);
75 }
76 let mut m = Map::new();
77 for p in pairs {
78 let (k, v) = p
79 .split_once('=')
80 .with_context(|| format!("--meta `{p}` must be key=value"))?;
81 let value = match serde_json::from_str::<Value>(v) {
82 Ok(j @ (Value::Number(_) | Value::Bool(_) | Value::Null)) => j,
83 _ => Value::String(v.to_string()),
84 };
85 m.insert(k.trim().to_string(), value);
86 }
87 Ok(Some(Value::Object(m).to_string()))
88}
89
90pub fn tags(args_tags: &[String], defaults: &[String]) -> Option<String> {
91 let all: Vec<String> = args_tags
92 .iter()
93 .flat_map(|t| t.split(','))
94 .chain(defaults.iter().map(String::as_str))
95 .map(str::trim)
96 .filter(|t| !t.is_empty())
97 .map(str::to_string)
98 .collect();
99 (!all.is_empty()).then(|| all.join(","))
100}
101
102pub async fn push_path(
104 ctx: &Ctx,
105 client: &Client,
106 path: &Path,
107 explicit_id: Option<&str>,
108 force_new: bool,
109 query: &PushQuery,
110) -> anyhow::Result<PushResponse> {
111 let p = payload(path)?;
112 let mut q = query.clone();
113 q.filename = p.filename.clone();
114 let server = client.origin();
115 let config_dir = &ctx.settings.config_dir;
116 let remembered = if force_new || explicit_id.is_some() {
117 None
118 } else {
119 remember::load(config_dir, path)
120 .filter(|m| m.server == server)
121 .map(|m| m.id)
122 };
123 let explicit = explicit_id.map(docref::parse).transpose()?;
124 let target = explicit.clone().or(remembered.clone());
125 let resp = match target {
126 Some(id) => match client
127 .push(Some(&id), p.body.clone(), p.content_type, &q)
128 .await
129 {
130 Ok(r) => r,
131 Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
132 output::warn(format!(
133 "the remembered document {id} is gone; pushing a new document"
134 ));
135 client.push(None, p.body, p.content_type, &q).await?
136 }
137 Err(e) => return Err(e),
138 },
139 None => client.push(None, p.body, p.content_type, &q).await?,
140 };
141 let memo = Memo {
142 id: resp.id.clone(),
143 server,
144 };
145 if let Err(e) = remember::save(config_dir, path, &memo) {
146 output::warn(format!("could not remember the document id: {e:#}"));
147 }
148 Ok(resp)
149}
150
151pub fn print_push(ctx: &Ctx, r: &PushResponse) {
152 for b in &r.broken_links {
153 output::warn(format!("broken link in {}: {}", b.from, b.href));
154 }
155 for w in &r.warnings {
156 output::warn(w);
157 }
158 if ctx.json {
159 output::json(r);
160 return;
161 }
162 let files = if r.files == 1 {
163 "1 file".to_string()
164 } else {
165 format!("{} files", r.files)
166 };
167 println!(
168 "Pushed \"{}\" · r{} · {} · {}",
169 r.title, r.rev, r.kind, files
170 );
171 println!(" url {}", r.url);
172 println!(" ws {}", r.ws_url);
173 println!(" wait semora wait {}", r.id);
174}
175
176pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
177 let client = ctx.client()?;
178 let query = PushQuery {
179 title: args.title.clone(),
180 tags: tags(&args.tags, &ctx.settings.default_tags),
181 message: args.message.clone(),
182 entry: args.entry.clone(),
183 meta: parse_meta(&args.meta)?,
184 filename: None,
185 };
186 let resp = push_path(
187 ctx,
188 &client,
189 &args.path,
190 args.id.as_deref(),
191 args.new,
192 &query,
193 )
194 .await?;
195 if args.wait {
196 if !ctx.json {
197 print_push(ctx, &resp);
198 } else {
199 for b in &resp.broken_links {
201 output::warn(format!("broken link in {}: {}", b.from, b.href));
202 }
203 }
204 return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
205 }
206 print_push(ctx, &resp);
207 Ok(exit::OK)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn meta_values_keep_scalar_types() {
216 let m = parse_meta(&[
217 "a=1".into(),
218 "b=true".into(),
219 "c=hello world".into(),
220 "d=x=y".into(),
221 ])
222 .unwrap()
223 .unwrap();
224 let v: Value = serde_json::from_str(&m).unwrap();
225 assert_eq!(
226 v,
227 serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
228 );
229 assert!(parse_meta(&["novalue".into()]).is_err());
230 assert_eq!(parse_meta(&[]).unwrap(), None);
231 }
232
233 #[test]
234 fn tags_merge_flags_and_defaults() {
235 assert_eq!(
236 tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
237 Some("a,b,c,proj")
238 );
239 assert_eq!(tags(&[], &[]), None);
240 }
241}