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