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::project_link::{self, Link};
13use crate::remember::{self, Memo};
14use crate::{Ctx, docref, exit, output};
15
16pub struct Payload {
18 pub body: Vec<u8>,
19 pub content_type: &'static str,
20 pub filename: Option<String>,
21}
22
23pub fn payload(path: &Path) -> anyhow::Result<Payload> {
24 let meta =
25 std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
26 if meta.is_dir() {
27 let packed = bundle::pack_dir(path)?;
28 for (p, why) in &packed.skipped {
29 output::warn(format!("skipped {p} ({why})"));
30 }
31 if packed.files.is_empty() {
32 bail!("{} has no files Semora can serve", path.display());
33 }
34 if !packed.files.iter().any(|f| bundle::is_page(f)) {
35 bail!("{} has no Markdown or HTML page", path.display());
36 }
37 return Ok(Payload {
38 body: packed.tar,
39 content_type: "application/x-tar",
40 filename: None,
41 });
42 }
43 let name = path
44 .file_name()
45 .map(|n| n.to_string_lossy().into_owned())
46 .unwrap_or_default();
47 let lower = name.to_ascii_lowercase();
48 let content_type = if lower.ends_with(".md") || lower.ends_with(".markdown") {
49 "text/markdown; charset=utf-8"
50 } else if lower.ends_with(".html") || lower.ends_with(".htm") {
51 "text/html; charset=utf-8"
52 } else {
53 bail!("{name}: push a .md or .html file, or a folder");
54 };
55 let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
56 if content_type.starts_with("text/markdown") {
57 let links = bundle::local_page_links(&String::from_utf8_lossy(&body));
58 if !links.is_empty() {
59 output::warn(format!(
60 "{name} links to local pages ({}); push the folder to publish them together as a bundle",
61 links.join(", ")
62 ));
63 }
64 }
65 Ok(Payload {
66 body,
67 content_type,
68 filename: Some(name),
69 })
70}
71
72pub fn parse_meta(pairs: &[String]) -> anyhow::Result<Option<String>> {
74 if pairs.is_empty() {
75 return Ok(None);
76 }
77 let mut m = Map::new();
78 for p in pairs {
79 let (k, v) = p
80 .split_once('=')
81 .with_context(|| format!("--meta `{p}` must be key=value"))?;
82 let value = match serde_json::from_str::<Value>(v) {
83 Ok(j @ (Value::Number(_) | Value::Bool(_) | Value::Null)) => j,
84 _ => Value::String(v.to_string()),
85 };
86 m.insert(k.trim().to_string(), value);
87 }
88 Ok(Some(Value::Object(m).to_string()))
89}
90
91pub fn tags(args_tags: &[String], defaults: &[String]) -> Option<String> {
92 let all: Vec<String> = args_tags
93 .iter()
94 .flat_map(|t| t.split(','))
95 .chain(defaults.iter().map(String::as_str))
96 .map(str::trim)
97 .filter(|t| !t.is_empty())
98 .map(str::to_string)
99 .collect();
100 (!all.is_empty()).then(|| all.join(","))
101}
102
103pub fn note_outside_project(path: &Path, id: Option<&str>, new: bool) {
105 if id.is_none() && !new {
106 return;
107 }
108 let Ok(Some(link)) = project_link::find(path) else {
109 return;
110 };
111 if id.is_some() {
112 eprintln!(
113 "note: --id pushes to that document directly, not into project {}.",
114 link.slug
115 );
116 } else {
117 eprintln!(
118 "note: --new makes a document outside project {}.",
119 link.slug
120 );
121 }
122}
123
124pub fn hint_outside_project(err: anyhow::Error, flags: &str) -> anyhow::Error {
127 if err.downcast_ref::<project_link::OtherServer>().is_some() {
128 anyhow::anyhow!("{err} Or use {flags} to push outside the project.")
129 } else {
130 err
131 }
132}
133
134pub fn check_filed(resp: &PushResponse, slug: &str) -> anyhow::Result<()> {
136 if resp.project.is_none() {
137 bail!(
138 "the server did not file this under project `{slug}`; it may be older than this CLI. Update the server."
139 );
140 }
141 Ok(())
142}
143
144async fn push_keyed(
146 ctx: &Ctx,
147 client: &Client,
148 path: &Path,
149 link: &Link,
150 key: &str,
151 p: Payload,
152 mut q: PushQuery,
153) -> anyhow::Result<PushResponse> {
154 let server = client.origin();
155 q.project = Some(link.slug.clone());
156 q.path = Some(key.to_string());
157 q.origin = Some(project_link::origin_for(path));
158 q.adopt = remember::load(&ctx.settings.config_dir, path)
160 .filter(|m| m.server == server)
161 .map(|m| m.id);
162 let resp = match client.push(None, p.body, p.content_type, &q).await {
163 Ok(r) => r,
164 Err(e) if exit::is_not_found(&e) => {
165 return Err(exit::not_found(format!(
166 "project `{}` was not found on {server}. Run `semora project init` to create it again, or you may no longer have access to this project.",
167 link.slug
168 )));
169 }
170 Err(e) if exit::is_conflict(&e) => bail!(
171 "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.",
172 link.slug
173 ),
174 Err(e) => return Err(e),
175 };
176 check_filed(&resp, &link.slug)?;
177 Ok(resp)
178}
179
180pub async fn push_path(
183 ctx: &Ctx,
184 client: &Client,
185 path: &Path,
186 explicit_id: Option<&str>,
187 force_new: bool,
188 query: &PushQuery,
189) -> anyhow::Result<PushResponse> {
190 let keyed = if explicit_id.is_none() && !force_new {
192 match project_link::find(path)? {
193 Some(link) => {
194 link.check_server(client)?;
195 let key = link.relative_path(path)?;
196 Some((link, key))
197 }
198 None => None,
199 }
200 } else {
201 None
202 };
203 let p = payload(path)?;
204 let mut q = query.clone();
205 q.filename = p.filename.clone();
206 if let Some((link, key)) = &keyed {
207 return push_keyed(ctx, client, path, link, key, p, q).await;
208 }
209 let server = client.origin();
210 let config_dir = &ctx.settings.config_dir;
211 let remembered = if force_new || explicit_id.is_some() {
212 None
213 } else {
214 remember::load(config_dir, path)
215 .filter(|m| m.server == server)
216 .map(|m| m.id)
217 };
218 let explicit = match explicit_id {
219 Some(s) => Some(docref::resolve(client, s).await?),
220 None => None,
221 };
222 let target = explicit.clone().or(remembered.clone());
223 let resp = match target {
224 Some(id) => match client
225 .push(Some(&id), p.body.clone(), p.content_type, &q)
226 .await
227 {
228 Ok(r) => r,
229 Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
230 output::warn(format!(
231 "the remembered document {id} is gone; pushing a new document"
232 ));
233 client.push(None, p.body, p.content_type, &q).await?
234 }
235 Err(e) => return Err(e),
236 },
237 None => client.push(None, p.body, p.content_type, &q).await?,
238 };
239 let memo = Memo {
240 id: resp.id.clone(),
241 server,
242 };
243 if let Err(e) = remember::save(config_dir, path, &memo) {
244 output::warn(format!("could not remember the document id: {e:#}"));
245 }
246 Ok(resp)
247}
248
249pub fn print_push(ctx: &Ctx, r: &PushResponse) {
250 for b in &r.broken_links {
251 output::warn(format!("broken link in {}: {}", b.from, b.href));
252 }
253 for w in &r.warnings {
254 output::warn(w);
255 }
256 if ctx.json {
257 output::json(r);
258 return;
259 }
260 let files = if r.files == 1 {
261 "1 file".to_string()
262 } else {
263 format!("{} files", r.files)
264 };
265 println!(
266 "Pushed \"{}\" · r{} · {} · {}",
267 r.title, r.rev, r.kind, files
268 );
269 if let Some(u) = &r.path_url {
270 println!(" path {u}");
271 }
272 println!(" url {}", r.url);
273 println!(" ws {}", r.ws_url);
274 println!(" wait semora wait {}", r.id);
275}
276
277pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
278 let client = ctx.client()?;
279 note_outside_project(&args.path, args.id.as_deref(), args.new);
280 let query = PushQuery {
281 title: args.title.clone(),
282 tags: tags(&args.tags, &ctx.settings.default_tags),
283 message: args.message.clone(),
284 entry: args.entry.clone(),
285 meta: parse_meta(&args.meta)?,
286 ..Default::default()
287 };
288 let resp = push_path(
289 ctx,
290 &client,
291 &args.path,
292 args.id.as_deref(),
293 args.new,
294 &query,
295 )
296 .await
297 .map_err(|e| hint_outside_project(e, "--id or --new"))?;
298 if args.wait {
299 if !ctx.json {
300 print_push(ctx, &resp);
301 } else {
302 for b in &resp.broken_links {
304 output::warn(format!("broken link in {}: {}", b.from, b.href));
305 }
306 }
307 return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
308 }
309 print_push(ctx, &resp);
310 Ok(exit::OK)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn meta_values_keep_scalar_types() {
319 let m = parse_meta(&[
320 "a=1".into(),
321 "b=true".into(),
322 "c=hello world".into(),
323 "d=x=y".into(),
324 ])
325 .unwrap()
326 .unwrap();
327 let v: Value = serde_json::from_str(&m).unwrap();
328 assert_eq!(
329 v,
330 serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
331 );
332 assert!(parse_meta(&["novalue".into()]).is_err());
333 assert_eq!(parse_meta(&[]).unwrap(), None);
334 }
335
336 #[test]
337 fn an_old_server_without_projects_is_reported() {
338 let mut r = PushResponse {
339 id: "k3F9xQ2mLp".into(),
340 rev: 1,
341 kind: semora_proto::DocKind::Md,
342 url: "u".into(),
343 ws_url: "w".into(),
344 files: 1,
345 title: "T".into(),
346 broken_links: vec![],
347 warnings: vec![],
348 project: None,
349 path: None,
350 path_url: None,
351 };
352 let err = check_filed(&r, "semora").unwrap_err().to_string();
353 assert!(
354 err.contains("older than this CLI") && err.contains("semora"),
355 "{err}"
356 );
357 r.project = Some("semora".into());
358 assert!(check_filed(&r, "semora").is_ok());
359 }
360
361 #[test]
362 fn tags_merge_flags_and_defaults() {
363 assert_eq!(
364 tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
365 Some("a,b,c,proj")
366 );
367 assert_eq!(tags(&[], &[]), None);
368 }
369}