1#[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#[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
44pub 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#[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#[tauri::command]
118fn save_image(current: State<Current>, request: Request) -> Result<(), String> {
119 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
153pub 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}