notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
src/instance.rs (7.6K)
1 //! Single instance over the cce socket convention, in cce-browser's
2 //! claim-or-forward shape: the first `cce-notes` listens on
3 //! `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock` (keyed by display, so shadow
4 //! sessions stay apart), and every later launch — and any app that wants a
5 //! note open — sends it one line and exits.
6 //!
7 //! Commands, one per line, each answered `ok`:
8 //!
9 //! - `open <path>[#heading] [line]` — a vault path, an absolute path inside
10 //! the vault, or a note name; `line` is 1-based.
11 //! - `daily [YYYY-MM-DD]` — open (creating it from the template) a day's note.
12 //! - `search <query>` — show the search pane holding `query` (`#tag` too).
13 //! - `show` — nothing but bringing the instance up.
14 //! - `current` — answered `ok <vault path>` (or a bare `ok` with no note
15 //! open) straight from the listener thread; cce-graph's local graph
16 //! polls it to follow the note on screen.
17 //!
18 //! The claim, the startup race it closes, the parked listener and the
19 //! bounded reads are `cce_ui::ipc::instance`'s; this module is the notes
20 //! protocol on top.
21
22 use std::sync::Mutex;
23
24 use crate::Message;
25
26 const PREFIX: &str = "cce-notes";
27 /// The open note's vault path, for `current`. Set by the app whenever it
28 /// changes; read by the listener without a trip through the event loop.
29 static CURRENT: Mutex<Option<String>> = Mutex::new(None);
30
31 pub fn set_current(path: Option<&str>) {
32 *CURRENT.lock().unwrap_or_else(|e| e.into_inner()) = path.map(String::from);
33 }
34
35 #[derive(Debug, Clone, PartialEq)]
36 pub enum Command {
37 Open { target: String, line: Option<usize> },
38 Daily(Option<chrono::NaiveDate>),
39 Search(String),
40 Show,
41 }
42
43 impl Command {
44 /// The command line's own arguments as a socket command:
45 /// `cce-notes [open] <path> [line]`, `cce-notes daily [date]`,
46 /// `cce-notes search <query…>`, `cce-notes show`, or nothing (also
47 /// `show`). The verbs are reserved, as `daily` and `search` always were:
48 /// a note named `show` opens with `cce-notes open show`.
49 pub fn from_args(args: &[String]) -> Result<Command, String> {
50 let words: Vec<&str> = args.iter().map(String::as_str).collect();
51 match words.as_slice() {
52 [] | ["show"] => Ok(Command::Show),
53 ["daily"] => Ok(Command::Daily(None)),
54 ["daily", d] => parse_date(d).map(|d| Command::Daily(Some(d))),
55 ["search", rest @ ..] => Ok(Command::Search(rest.join(" "))),
56 ["open", target] | [target] => Ok(Command::Open { target: absolute(target), line: None }),
57 ["open", target, line] | [target, line] => {
58 let line = line.parse::<usize>().map_err(|_| format!("not a line number: {line}"))?;
59 Ok(Command::Open { target: absolute(target), line: Some(line) })
60 }
61 _ => Err("usage: cce-notes [open] <note> [line] | daily [YYYY-MM-DD] | search <query> | show".into()),
62 }
63 }
64
65 pub fn to_line(&self) -> String {
66 match self {
67 Command::Open { target, line: Some(l) } => format!("open {target} {l}"),
68 Command::Open { target, line: None } => format!("open {target}"),
69 Command::Daily(Some(d)) => format!("daily {d}"),
70 Command::Daily(None) => "daily".into(),
71 Command::Search(q) => format!("search {q}"),
72 Command::Show => "show".into(),
73 }
74 }
75
76 pub fn parse(line: &str) -> Option<Command> {
77 let (verb, rest) = line.split_once(' ').unwrap_or((line, ""));
78 let rest = rest.trim();
79 match verb {
80 "open" if !rest.is_empty() => {
81 // A trailing number is a line, unless it is all there is.
82 match rest.rsplit_once(' ') {
83 Some((t, l)) if l.parse::<usize>().is_ok() => {
84 Some(Command::Open { target: t.trim().to_string(), line: l.parse().ok() })
85 }
86 _ => Some(Command::Open { target: rest.to_string(), line: None }),
87 }
88 }
89 "daily" if rest.is_empty() => Some(Command::Daily(None)),
90 "daily" => parse_date(rest).ok().map(|d| Command::Daily(Some(d))),
91 "search" => Some(Command::Search(rest.to_string())),
92 "show" => Some(Command::Show),
93 _ => None,
94 }
95 }
96 }
97
98 fn parse_date(s: &str) -> Result<chrono::NaiveDate, String> {
99 chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| format!("not a date (YYYY-MM-DD): {s}"))
100 }
101
102 /// A path that exists here travels absolute: the instance's cwd differs.
103 /// Anything else (a note name, a vault path) goes as written.
104 fn absolute(target: &str) -> String {
105 let (path, sub) = match target.split_once('#') {
106 Some((p, s)) => (p, Some(s)),
107 None => (target, None),
108 };
109 let p = std::path::Path::new(path);
110 let abs = if p.exists() && !p.is_absolute() {
111 std::fs::canonicalize(p).ok().and_then(|c| c.to_str().map(String::from))
112 } else {
113 None
114 };
115 match (abs, sub) {
116 (Some(a), Some(s)) => format!("{a}#{s}"),
117 (Some(a), None) => a,
118 (None, _) => target.to_string(),
119 }
120 }
121
122 /// Hand `cmd` to a running instance, or claim the socket. True when a
123 /// running instance took it and this process should exit.
124 pub fn forward_or_claim(cmd: &Command) -> bool {
125 cce_ui::ipc::instance::forward_or_claim(PREFIX, &cmd.to_line())
126 }
127
128 /// Serve the claimed listener on a thread, feeding the app's loop. False
129 /// when this process holds no listener (single-instance handling failed
130 /// and it runs standalone).
131 pub fn spawn_listener(sender: calloop::channel::Sender<Message>) -> bool {
132 cce_ui::ipc::instance::serve(move |line| {
133 // Answered from here, without a trip through the event loop.
134 if line == "current" {
135 let cur = CURRENT.lock().unwrap_or_else(|e| e.into_inner()).clone();
136 return Some(match cur {
137 Some(p) => format!("ok {p}"),
138 None => "ok".to_string(),
139 });
140 }
141 match Command::parse(line) {
142 Some(cmd) => {
143 sender.send(Message::Command(cmd)).ok()?;
144 Some("ok".into())
145 }
146 None => Some("error unknown command".into()),
147 }
148 })
149 }
150
151 pub fn cleanup() {
152 cce_ui::ipc::instance::cleanup();
153 }
154
155 #[cfg(test)]
156 mod tests {
157 use super::*;
158
159 fn args(a: &[&str]) -> Vec<String> {
160 a.iter().map(|s| s.to_string()).collect()
161 }
162
163 #[test]
164 fn args_and_lines_round_trip() {
165 let cases = [
166 (vec![], Command::Show),
167 (vec!["show"], Command::Show),
168 (vec!["open", "show"], Command::Open { target: "show".into(), line: None }),
169 (vec!["Note"], Command::Open { target: "Note".into(), line: None }),
170 (vec!["open", "My Note#Sec", "12"], Command::Open { target: "My Note#Sec".into(), line: Some(12) }),
171 (vec!["daily"], Command::Daily(None)),
172 (vec!["daily", "2026-10-01"], Command::Daily(chrono::NaiveDate::from_ymd_opt(2026, 10, 1))),
173 (vec!["search", "two", "words"], Command::Search("two words".into())),
174 ];
175 for (a, want) in cases {
176 let got = Command::from_args(&args(&a)).unwrap();
177 assert_eq!(got, want, "{a:?}");
178 assert_eq!(Command::parse(&got.to_line()), Some(want));
179 }
180 assert!(Command::from_args(&args(&["daily", "tomorrow"])).is_err());
181 assert!(Command::parse("open ").is_none());
182 assert!(Command::parse("bogus").is_none());
183 // A note whose name is a number is a target, not a line.
184 assert_eq!(Command::parse("open 2026"), Some(Command::Open { target: "2026".into(), line: None }));
185 }
186 }