1use std::collections::BTreeMap;
4use std::path::Path;
5
6use anyhow::{Context, bail};
7use semora_proto::PushResponse;
8use serde_json::{Map, Value};
9
10use crate::args::PushArgs;
11use crate::bundle;
12use crate::checkout::{self, Record};
13use crate::client::{Client, PushQuery, encode_path};
14use crate::project_link::{self, Link};
15use crate::remember::{self, Memo};
16use crate::{Ctx, docref, exit, output};
17
18pub struct Payload {
20 pub body: Vec<u8>,
21 pub content_type: &'static str,
22 pub filename: Option<String>,
23 pub hashes: BTreeMap<String, String>,
25}
26
27pub fn payload(path: &Path) -> anyhow::Result<Payload> {
28 let meta =
29 std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
30 if meta.is_dir() {
31 let packed = bundle::pack_dir(path)?;
32 for (p, why) in &packed.skipped {
33 output::warn(format!("skipped {p} ({why})"));
34 }
35 if packed.files.is_empty() {
36 bail!("{} has no files Semora can serve", path.display());
37 }
38 if !packed.files.iter().any(|f| bundle::is_page(f)) {
39 bail!("{} has no Markdown or HTML page", path.display());
40 }
41 return Ok(Payload {
42 body: packed.tar,
43 content_type: "application/x-tar",
44 filename: None,
45 hashes: packed.hashes,
46 });
47 }
48 let name = path
49 .file_name()
50 .map(|n| n.to_string_lossy().into_owned())
51 .unwrap_or_default();
52 let lower = name.to_ascii_lowercase();
53 let content_type = if lower.ends_with(".md") || lower.ends_with(".markdown") {
54 "text/markdown; charset=utf-8"
55 } else if lower.ends_with(".html") || lower.ends_with(".htm") {
56 "text/html; charset=utf-8"
57 } else {
58 bail!("{name}: push a .md or .html file, or a folder");
59 };
60 let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
61 if content_type.starts_with("text/markdown") {
62 let links = bundle::local_page_links(&String::from_utf8_lossy(&body));
63 if !links.is_empty() {
64 output::warn(format!(
65 "{name} links to local pages ({}); push the folder to publish them together as a bundle",
66 links.join(", ")
67 ));
68 }
69 }
70 Ok(Payload {
71 hashes: [(String::new(), checkout::sha256(&body))].into(),
72 body,
73 content_type,
74 filename: Some(name),
75 })
76}
77
78pub fn parse_meta(pairs: &[String]) -> anyhow::Result<Option<String>> {
80 if pairs.is_empty() {
81 return Ok(None);
82 }
83 let mut m = Map::new();
84 for p in pairs {
85 let (k, v) = p
86 .split_once('=')
87 .with_context(|| format!("--meta `{p}` must be key=value"))?;
88 let value = match serde_json::from_str::<Value>(v) {
89 Ok(j @ (Value::Number(_) | Value::Bool(_) | Value::Null)) => j,
90 _ => Value::String(v.to_string()),
91 };
92 m.insert(k.trim().to_string(), value);
93 }
94 Ok(Some(Value::Object(m).to_string()))
95}
96
97pub fn tags(args_tags: &[String], defaults: &[String]) -> Option<String> {
98 let all: Vec<String> = args_tags
99 .iter()
100 .flat_map(|t| t.split(','))
101 .chain(defaults.iter().map(String::as_str))
102 .map(str::trim)
103 .filter(|t| !t.is_empty())
104 .map(str::to_string)
105 .collect();
106 (!all.is_empty()).then(|| all.join(","))
107}
108
109pub fn note_outside_project(path: &Path, id: Option<&str>, new: bool) {
111 if id.is_none() && !new {
112 return;
113 }
114 let Ok(Some(link)) = project_link::find(path) else {
115 return;
116 };
117 if id.is_some() {
118 eprintln!(
119 "note: --id pushes to that document directly, not into project {}.",
120 link.slug
121 );
122 } else {
123 eprintln!(
124 "note: --new makes a document outside project {}.",
125 link.slug
126 );
127 }
128}
129
130pub fn hint_outside_project(err: anyhow::Error, flags: &str) -> anyhow::Error {
133 if err.downcast_ref::<project_link::OtherServer>().is_some() {
134 anyhow::anyhow!("{err} Or use {flags} to push outside the project.")
135 } else {
136 err
137 }
138}
139
140pub fn check_filed(resp: &PushResponse, slug: &str) -> anyhow::Result<()> {
142 if resp.project.is_none() {
143 bail!(
144 "the server did not file this under project `{slug}`; it may be older than this CLI. Update the server."
145 );
146 }
147 Ok(())
148}
149
150fn stale_push(
152 client: &Client,
153 link: &Link,
154 key: &str,
155 path: &Path,
156 bundle: bool,
157 err: &anyhow::Error,
158) -> anyhow::Error {
159 let shown = path.display();
160 let url = client.url(&format!("/p/{}/{}", link.slug, encode_path(key)));
161 let get = if bundle {
162 format!("`semora show {url}` lists its files, `semora get {url} FILE` prints one")
163 } else {
164 format!("`semora get {url}` prints it")
165 };
166 exit::stale(format!(
167 "{}. 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.",
168 exit::message_of(err)
169 ))
170}
171
172#[allow(clippy::too_many_arguments)]
176async fn push_keyed(
177 ctx: &Ctx,
178 client: &Client,
179 path: &Path,
180 link: &Link,
181 key: &str,
182 p: Payload,
183 mut q: PushQuery,
184 force: bool,
185) -> anyhow::Result<PushResponse> {
186 let server = client.origin();
187 q.project = Some(link.slug.clone());
188 q.path = Some(key.to_string());
189 q.origin = Some(project_link::origin_for(path));
190 q.adopt = remember::load(&ctx.settings.config_dir, path)
192 .filter(|m| m.server == server)
193 .map(|m| m.id);
194 let known = if force {
195 None
196 } else {
197 checkout::load(link)?.remove(key)
198 };
199 let if_match = known.map(|r| {
200 q.base = Some(r.id);
201 r.rev
202 });
203 let files: BTreeMap<String, String> = p
204 .hashes
205 .iter()
206 .map(|(rel, hash)| {
207 let local = if rel.is_empty() {
208 key.to_string()
209 } else {
210 format!("{key}/{rel}")
211 };
212 (local, hash.clone())
213 })
214 .collect();
215 let bundle = p.filename.is_none();
216 let resp = match client
217 .push(None, p.body, p.content_type, &q, if_match)
218 .await
219 {
220 Ok(r) => r,
221 Err(e) if exit::is_stale(&e) => {
222 return Err(stale_push(client, link, key, path, bundle, &e));
223 }
224 Err(e) if exit::is_not_found(&e) => {
225 return Err(exit::not_found(format!(
226 "project `{}` was not found on {server}. Run `semora project init` to create it again, or you may no longer have access to this project.",
227 link.slug
228 )));
229 }
230 Err(e) if exit::is_conflict(&e) => bail!(
231 "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.",
232 link.slug
233 ),
234 Err(e) => return Err(e),
235 };
236 check_filed(&resp, &link.slug)?;
237 let record = Record {
238 id: resp.id.clone(),
239 rev: resp.rev,
240 kind: resp.kind,
241 files,
242 };
243 if let Err(e) = checkout::update(link, |r| r.insert(key.to_string(), record)) {
244 output::warn(format!(
245 "could not record the pushed revision, so the next push of {key} can't check for newer ones: {e:#}"
246 ));
247 }
248 Ok(resp)
249}
250
251pub async fn push_path(
255 ctx: &Ctx,
256 client: &Client,
257 path: &Path,
258 explicit_id: Option<&str>,
259 force_new: bool,
260 force: bool,
261 query: &PushQuery,
262) -> anyhow::Result<PushResponse> {
263 let keyed = if explicit_id.is_none() && !force_new {
265 match project_link::find(path)? {
266 Some(link) => {
267 link.check_server(client)?;
268 let key = link.relative_path(path)?;
269 Some((link, key))
270 }
271 None => None,
272 }
273 } else {
274 None
275 };
276 let p = payload(path)?;
277 let mut q = query.clone();
278 q.filename = p.filename.clone();
279 if let Some((link, key)) = &keyed {
280 return push_keyed(ctx, client, path, link, key, p, q, force).await;
281 }
282 let server = client.origin();
283 let config_dir = &ctx.settings.config_dir;
284 let remembered = if force_new || explicit_id.is_some() {
285 None
286 } else {
287 remember::load(config_dir, path)
288 .filter(|m| m.server == server)
289 .map(|m| m.id)
290 };
291 let explicit = match explicit_id {
292 Some(s) => Some(docref::resolve(client, s).await?),
293 None => None,
294 };
295 let target = explicit.clone().or(remembered.clone());
296 let resp = match target {
297 Some(id) => match client
298 .push(Some(&id), p.body.clone(), p.content_type, &q, None)
299 .await
300 {
301 Ok(r) => r,
302 Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
303 output::warn(format!(
304 "the remembered document {id} is gone; pushing a new document"
305 ));
306 client.push(None, p.body, p.content_type, &q, None).await?
307 }
308 Err(e) => return Err(e),
309 },
310 None => client.push(None, p.body, p.content_type, &q, None).await?,
311 };
312 let memo = Memo {
313 id: resp.id.clone(),
314 server,
315 };
316 if let Err(e) = remember::save(config_dir, path, &memo) {
317 output::warn(format!("could not remember the document id: {e:#}"));
318 }
319 Ok(resp)
320}
321
322fn note_unrecorded_message(r: &PushResponse, message: Option<&str>) {
325 if r.unchanged && message.is_some_and(|m| !m.trim().is_empty()) {
326 eprintln!(
327 "note: the content was unchanged, so no revision was made and the message was not recorded."
328 );
329 }
330}
331
332pub fn print_push(ctx: &Ctx, r: &PushResponse) {
333 for b in &r.broken_links {
334 output::warn(format!("broken link in {}: {}", b.from, b.href));
335 }
336 for w in &r.warnings {
337 output::warn(w);
338 }
339 if ctx.json {
340 output::json(r);
341 return;
342 }
343 let files = if r.files == 1 {
344 "1 file".to_string()
345 } else {
346 format!("{} files", r.files)
347 };
348 if r.unchanged {
349 println!(
350 "Nothing changed: \"{}\" stays at r{} · {} · {}",
351 r.title, r.rev, r.kind, files
352 );
353 } else {
354 println!(
355 "Pushed \"{}\" · r{} · {} · {}",
356 r.title, r.rev, r.kind, files
357 );
358 }
359 if let Some(u) = &r.path_url {
360 println!(" path {u}");
361 }
362 println!(" url {}", r.url);
363 println!(" ws {}", r.ws_url);
364 println!(" wait semora wait {}", r.id);
365}
366
367pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
368 let client = ctx.client()?;
369 note_outside_project(&args.path, args.id.as_deref(), args.new);
370 let query = PushQuery {
371 title: args.title.clone(),
372 tags: tags(&args.tags, &ctx.settings.default_tags),
373 message: args.message.clone(),
374 entry: args.entry.clone(),
375 meta: parse_meta(&args.meta)?,
376 ..Default::default()
377 };
378 let resp = push_path(
379 ctx,
380 &client,
381 &args.path,
382 args.id.as_deref(),
383 args.new,
384 args.force,
385 &query,
386 )
387 .await
388 .map_err(|e| hint_outside_project(e, "--id or --new"))?;
389 note_unrecorded_message(&resp, args.message.as_deref());
390 if args.wait {
391 if !ctx.json {
392 print_push(ctx, &resp);
393 } else {
394 for b in &resp.broken_links {
396 output::warn(format!("broken link in {}: {}", b.from, b.href));
397 }
398 }
399 return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
400 }
401 print_push(ctx, &resp);
402 Ok(exit::OK)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn meta_values_keep_scalar_types() {
411 let m = parse_meta(&[
412 "a=1".into(),
413 "b=true".into(),
414 "c=hello world".into(),
415 "d=x=y".into(),
416 ])
417 .unwrap()
418 .unwrap();
419 let v: Value = serde_json::from_str(&m).unwrap();
420 assert_eq!(
421 v,
422 serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
423 );
424 assert!(parse_meta(&["novalue".into()]).is_err());
425 assert_eq!(parse_meta(&[]).unwrap(), None);
426 }
427
428 #[test]
429 fn an_old_server_without_projects_is_reported() {
430 let mut r = PushResponse {
431 id: "k3F9xQ2mLp".into(),
432 rev: 1,
433 kind: semora_proto::DocKind::Md,
434 url: "u".into(),
435 ws_url: "w".into(),
436 files: 1,
437 title: "T".into(),
438 unchanged: false,
439 broken_links: vec![],
440 warnings: vec![],
441 project: None,
442 path: None,
443 path_url: None,
444 };
445 let err = check_filed(&r, "semora").unwrap_err().to_string();
446 assert!(
447 err.contains("older than this CLI") && err.contains("semora"),
448 "{err}"
449 );
450 r.project = Some("semora".into());
451 assert!(check_filed(&r, "semora").is_ok());
452 }
453
454 #[test]
455 fn tags_merge_flags_and_defaults() {
456 assert_eq!(
457 tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
458 Some("a,b,c,proj")
459 );
460 assert_eq!(tags(&[], &[]), None);
461 }
462}