Skip to main content

semora_cli/commands/
push.rs

1//! `semora push`: publish a file or folder.
2
3use 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
16/// What to send for `path`: body, content type, single-file name, warnings.
17pub 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
72/// `key=value` pairs; values that parse as JSON scalars keep their type.
73pub 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
103/// One line when `--id` or `--new` pushes outside the project that holds `path`.
104pub 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
124/// Adds a command's own way out of a `.semora` for another server: `flags` is `--id or --new`
125/// for push, `--id` for watch. Other errors pass through.
126pub 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
134/// An old server ignores the project parameters and makes a plain document.
135pub 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
144/// A push by key: the server updates the document at `key` in the project, or makes one.
145async 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    // An older document remembered for this path is carried into the project.
159    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
180/// Pushes `path`. Inside a project (a `.semora` above it) it goes by key, unless `--id` or
181/// `--new` says otherwise; outside one it replaces the explicit or remembered document.
182pub 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    // Decided before anything is read or packed, so a refusal costs nothing.
191    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            // With --wait --json, stdout carries only the events.
303            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}