Skip to main content

yeda_reader_lib/
lib.rs

1//! The reader's Tauri commands; yeda-android registers them next to its own.
2
3#[cfg(desktop)]
4pub mod fetch;
5
6use std::path::PathBuf;
7use std::sync::Mutex;
8
9use serde::Serialize;
10use serde_json::{Map, Value as Json};
11use tauri::State;
12use tauri::ipc::{InvokeBody, Request, Response};
13use yeda_core::annotations;
14
15pub struct Doc {
16    pub pdf: PathBuf,
17    pub annotations: PathBuf,
18    pub page: Option<u32>,
19}
20
21impl Doc {
22    pub fn with_sidecar(pdf: PathBuf, page: Option<u32>) -> Doc {
23        let mut sidecar = pdf.clone().into_os_string();
24        sidecar.push(".annotations");
25        Doc { pdf, annotations: sidecar.into(), page }
26    }
27}
28
29/// The open document; desktop sets it from the command line, Android when a reference or file is opened.
30#[derive(Default)]
31pub struct Current(pub Mutex<Option<Doc>>);
32
33impl Current {
34    fn with<T>(&self, f: impl FnOnce(&Doc) -> anyhow::Result<T>) -> Result<T, String> {
35        let doc = self.0.lock().unwrap();
36        f(doc.as_ref().ok_or("no document open")?).map_err(|e| format!("{e:#}"))
37    }
38
39    pub fn open(&self, doc: Doc) {
40        *self.0.lock().unwrap() = Some(doc);
41    }
42}
43
44/// Every command `invoke_handler` registers.
45pub const COMMANDS: &[&str] =
46    &["launch", "read_pdf", "load_annotations", "save_annotations", "delete_annotations", "read_image", "save_image"];
47
48pub fn invoke_handler<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -> bool + Send + Sync + 'static {
49    tauri::generate_handler![launch, read_pdf, load_annotations, save_annotations, delete_annotations, read_image, save_image]
50}
51
52#[derive(Serialize)]
53struct Launch {
54    title: String,
55    page: Option<u32>,
56}
57
58#[tauri::command]
59fn launch(current: State<Current>) -> Option<Launch> {
60    current
61        .with(|doc| {
62            let title = doc.pdf.file_stem().unwrap_or_default().to_string_lossy().into_owned();
63            Ok(Launch { title, page: doc.page })
64        })
65        .ok()
66}
67
68#[tauri::command]
69fn read_pdf(current: State<Current>) -> Result<Response, String> {
70    current.with(|doc| std::fs::read(&doc.pdf).map(Response::new).map_err(|e| anyhow::anyhow!("{}: {e}", doc.pdf.display())))
71}
72
73fn annotations_file(doc: &Doc) -> PathBuf {
74    doc.annotations.join(annotations::FILE)
75}
76
77#[tauri::command]
78fn load_annotations(current: State<Current>) -> Result<Vec<Json>, String> {
79    current.with(|doc| annotations::to_json(yeda_fs::read_optional(&annotations_file(doc))?.as_deref()))
80}
81
82/// Re-reads the file right before merging, since yeda-tui may have written it since.
83#[tauri::command]
84fn save_annotations(current: State<Current>, annotations: Vec<Map<String, Json>>) -> Result<(), String> {
85    current.with(|doc| {
86        yeda_fs::locked(&doc.annotations, || {
87            let path = annotations_file(doc);
88            let merged = annotations::merge(yeda_fs::read_optional(&path)?.as_deref(), annotations)?;
89            Ok(yeda_fs::write_atomic(&path, merged.as_bytes())?)
90        })
91    })
92}
93
94#[tauri::command]
95fn delete_annotations(current: State<Current>, ids: Vec<String>) -> Result<(), String> {
96    current.with(|doc| {
97        yeda_fs::locked(&doc.annotations, || {
98            let path = annotations_file(doc);
99            let (kept, images) = annotations::delete(yeda_fs::read_optional(&path)?.as_deref(), &ids)?;
100            yeda_fs::write_atomic(&path, kept.as_bytes())?;
101            let effects = images.into_iter().map(yeda_core::Effect::remove);
102            yeda_fs::apply(&doc.annotations, effects)
103        })
104    })
105}
106
107fn image_path(doc: &Doc, id: &str) -> anyhow::Result<PathBuf> {
108    Ok(doc.annotations.join(annotations::image_name(id)?))
109}
110
111#[tauri::command]
112fn read_image(current: State<Current>, id: String) -> Result<Response, String> {
113    current.with(|doc| Ok(Response::new(std::fs::read(image_path(doc, &id)?)?)))
114}
115
116/// Takes the PNG as the raw request body and the annotation id in the `id` header.
117#[tauri::command]
118fn save_image(current: State<Current>, request: Request) -> Result<(), String> {
119    // Android's postMessage IPC has no raw bodies; it sends the bytes as a JSON array.
120    let png = match request.body() {
121        InvokeBody::Raw(png) => png.clone(),
122        InvokeBody::Json(v) => serde_json::from_value(v.clone()).map_err(|_| "expected PNG bytes")?,
123    };
124    let id = request.headers().get("id").and_then(|v| v.to_str().ok()).ok_or("missing id header")?;
125    current.with(|doc| Ok(yeda_fs::write_atomic(&image_path(doc, id)?, &png)?))
126}
127
128const USAGE: &str = "usage: yeda-reader <file.pdf> [--annotations <dir>] [--page N]";
129
130fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Doc, String> {
131    let mut pdf = None;
132    let mut annotations = None;
133    let mut page = None;
134    while let Some(arg) = args.next() {
135        match arg.as_str() {
136            "--annotations" => annotations = Some(PathBuf::from(args.next().ok_or(USAGE)?)),
137            "--page" => {
138                let n = args.next().ok_or(USAGE)?;
139                page = Some(n.parse().map_err(|_| format!("invalid page: {n}"))?);
140            }
141            _ if pdf.is_none() && !arg.starts_with("--") => pdf = Some(PathBuf::from(arg)),
142            _ => return Err(USAGE.into()),
143        }
144    }
145    let doc = Doc::with_sidecar(pdf.ok_or(USAGE)?, page);
146    Ok(Doc { annotations: annotations.unwrap_or(doc.annotations), ..doc })
147}
148
149pub fn run() {
150    run_with(std::env::args().skip(1))
151}
152
153/// Runs the reader with these command-line arguments (without the program name).
154pub fn run_with(args: impl Iterator<Item = String>) {
155    let doc = parse_args(args).unwrap_or_else(|e| {
156        eprintln!("{e}");
157        std::process::exit(2);
158    });
159    let current = Current::default();
160    current.open(doc);
161    tauri::Builder::default()
162        .manage(current)
163        .invoke_handler(invoke_handler())
164        .run(tauri::generate_context!())
165        .expect("error while running tauri application");
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::path::Path;
172
173    #[test]
174    fn sidecar_defaults_next_to_pdf() {
175        let doc = parse_args(["a/b.pdf".to_string(), "--page".into(), "3".into()].into_iter()).unwrap();
176        assert_eq!(doc.annotations, Path::new("a/b.pdf.annotations"));
177        assert_eq!(doc.page, Some(3));
178        let doc = parse_args(["b.pdf".to_string(), "--annotations".into(), "lib/annotations/k".into()].into_iter()).unwrap();
179        assert_eq!(doc.annotations, Path::new("lib/annotations/k"));
180    }
181}