From 74d3296882f7bb9d97df18ac5a9187937a18ecfa Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk <201152+ok2@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:53:23 +0200 Subject: [PATCH] feat: Notion backend for the catalog + set command + ls/source/save UX Persist the entry catalog to a Notion page instead of an external script. - `hel store notion:` reads the dump from stdin and writes it into a single code block on the page; `hel load notion:` prints it back. `` is a page id (UUID) or a page title (case-insensitive search). Token from HEL_NOTION_TOKEN. Networking lives in helcli (ureq + serde_json); the core lib and the wasm build are untouched. argv is dispatched before the REPL starts, so the subcommands never read ~/.helrc or prompt for a master. - `set `: runtime config (e.g. hel_dump, hel_notion_token) from ~/.helrc instead of env vars. The map is exported (uppercased) into spawned pipe/source children, so `set hel_notion_token ...` reaches `hel store`. Values are redacted in history. - `ls` now matches case-insensitively. - `source` echoes `source `. - `save` prints a `< removed` / `> added` diff against the last load/save snapshot so removals are noticed before they persist. --- README.md | 60 +++++++++ hel/src/commands.rs | 76 +++++++++--- hel/src/lk.rs | 4 + hel/src/parser.rs | 3 +- hel/src/repl.rs | 1 + hel/src/structs.rs | 33 +++++ hel/src/utils.rs | 3 + helcli/Cargo.toml | 2 + helcli/src/bin/hel.rs | 22 +++- helcli/src/lib.rs | 88 ++++++++++++++ helcli/src/notion.rs | 277 ++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 550 insertions(+), 19 deletions(-) create mode 100644 helcli/src/lib.rs create mode 100644 helcli/src/notion.rs diff --git a/README.md b/README.md index 2b47cbb..d2f110c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,63 @@ # Hel: password generator and manager Hel is a password manager which newer stores the passwords but generates them according to the rules from the master password and the given name of the password. + +## Commands & behavior + +- `ls ` matches **case-insensitively** (name, full line, and comment). Add + an inline `(?-i)` to the pattern to force case-sensitive. +- `source ` echoes `source ` so you see what was loaded. +- `save` prints a diff against the previously loaded/saved snapshot before + persisting: `< line` = removed, `> line` = added — so an accidental removal is + visible on the save that would persist it. (Set-based, so dump ordering is + irrelevant.) The baseline is seeded on startup load and refreshed on each save. + +## Persistence + +Hel never stores secrets — only the entry catalog (names + generation rules), +which is a script of `add …` lines. That script can be saved to / loaded from a +file, an external command, or a **Notion** page. + +### `set`: configuration from `~/.helrc` + +Instead of environment variables, settings can live in the init script via +`set `: + +``` +set hel_dump |hel store notion:somenote +set hel_notion_token secret_xxxxxxxx +source hel load notion:somenote| +``` + +- Keys are case-insensitive. A `set` is never written to the shell history, and + its value is redacted in any echo (it may be a secret token). +- Lookups check the `set` map first, then the matching `UPPERCASE` environment + variable. So `set hel_dump …` overrides `$HEL_DUMP`, etc. +- When hel runs a pipe/source command, the whole `set` map is exported (uppercased) + into that child process — that is how `set hel_notion_token …` reaches a spawned + `hel store` / `hel load`. + +### Notion backend + +The `hel` binary doubles as the Notion backend through two non-interactive +subcommands, designed to drop into the existing pipe plumbing: + +- `hel store notion:` — read the dump from **stdin** and write it into the + Notion page (one code block, replaced in place). +- `hel load notion:` — fetch the page and print the dump to **stdout**. + +`` is either a Notion page id (UUID) or a page **title** (resolved by search). +The integration token comes from `HEL_NOTION_TOKEN` (or `set hel_notion_token …`), +and the target page must be shared with that integration. + +Wire it up so `save` pushes to Notion and startup pulls from it: + +``` +# ~/.helrc +set hel_notion_token secret_xxxxxxxx +set hel_dump |hel store notion:somenote +source hel load notion:somenote| +``` + +(Equivalently, `export HEL_DUMP="|hel store notion:somenote"` and +`export HEL_NOTION_TOKEN=…` in the shell.) diff --git a/hel/src/commands.rs b/hel/src/commands.rs index de59ec7..4b4e76a 100644 --- a/hel/src/commands.rs +++ b/hel/src/commands.rs @@ -10,7 +10,7 @@ use crate::parser::command_parser; use crate::password::fix_password_recursion; use crate::password::{Name, Password, PasswordRef}; use crate::repl::LKEval; -use crate::structs::{LKOut, Radix, CORRECT_FILE, DUMP_FILE}; +use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE}; use crate::utils::editor::password; use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd}; @@ -233,6 +233,7 @@ impl<'a> LKEval<'a> { } pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool { + out.o(format!("source {}", source)); let script = if source.trim().ends_with("|") { let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) { Ok(c) => c, @@ -272,15 +273,54 @@ impl<'a> LKEval<'a> { out.e(format!("error: {}", e.to_string())); } }; + // Baseline for the next save diff: the just-loaded state is the new + // "previously persisted" reference. + let snapshot = self.serialize_db(); + self.state.lock().borrow_mut().last_dump = Some(snapshot); false } + /// All entries serialized as sorted `add …` lines (the dump format). + fn serialize_db(&self) -> String { + let mut vals: Vec = self.state.lock().borrow().db.values().cloned().collect(); + vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name)); + vals.iter() + .map(|v| format!("add {}", v.lock().borrow().to_string())) + .collect::>() + .join("\n") + } + + /// Emit a `< removed` / `> added` line diff of `new` against the last saved/ + /// loaded snapshot (set-based, so dump ordering doesn't matter). No-op until + /// a baseline exists. + fn show_dump_diff(&self, out: &LKOut, new: &str) { + let prev = self.state.lock().borrow().last_dump.clone(); + if let Some(prev) = prev { + use std::collections::BTreeSet; + let p: BTreeSet<&str> = prev.lines().filter(|l| !l.is_empty()).collect(); + let n: BTreeSet<&str> = new.lines().filter(|l| !l.is_empty()).collect(); + for l in p.difference(&n) { + out.o(format!("< {}", l)); + } + for l in n.difference(&p) { + out.o(format!("> {}", l)); + } + } + } + + pub fn cmd_set(&self, out: &LKOut, key: &String, value: &String) { + config_set(key, value); + // Confirm without echoing the value — it may be a secret. + out.o(format!("set {}", key.to_lowercase())); + } + pub fn cmd_dump(&self, out: &LKOut, script: &Option) { - let script = match script { - Some(p) => p, - None => DUMP_FILE.to_str().unwrap(), + // Default dump target: `set hel_dump …` > $HEL_DUMP > ~/.hel_dump. + let script: String = match script { + Some(p) => p.clone(), + None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()), }; - let script = shellexpand::full(script).unwrap().into_owned(); + let script = shellexpand::full(&script).unwrap().into_owned(); fn save_dump(data: &HashMap, script: &String) -> std::io::Result<()> { let file = fs::File::create(script)?; let mut writer = BufWriter::new(file); @@ -299,15 +339,8 @@ impl<'a> LKEval<'a> { return; } }; - let data = self - .state - .lock() - .borrow() - .db - .values() - .map(|v| format!("add {}", v.lock().borrow().to_string())) - .collect::>() - .join("\n"); + let data = self.serialize_db(); + self.show_dump_diff(out, &data); let output = match call_cmd_with_input(&cmd, &args, data.as_str()) { Ok(o) => o, Err(e) => { @@ -315,6 +348,7 @@ impl<'a> LKEval<'a> { return; } }; + self.state.lock().borrow_mut().last_dump = Some(data); if output.len() > 0 { out.e(format!("Passwords saved to command {} and got following output:", cmd)); out.o(output); @@ -328,8 +362,15 @@ impl<'a> LKEval<'a> { out.o(format!("add {}", pwd.lock().borrow().to_string())) } } else { - match save_dump(&self.state.lock().borrow().db, &script) { - Ok(()) => out.o(format!("Passwords saved to file {}", script)), + let data = self.serialize_db(); + self.show_dump_diff(out, &data); + // Bind first so the immutable borrow drops before the borrow_mut below. + let res = save_dump(&self.state.lock().borrow().db, &script); + match res { + Ok(()) => { + self.state.lock().borrow_mut().last_dump = Some(data); + out.o(format!("Passwords saved to file {}", script)); + } Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())), }; } @@ -339,7 +380,8 @@ impl<'a> LKEval<'a> { where F: Fn(&PasswordRef, &PasswordRef) -> std::cmp::Ordering, { - let re = match Regex::new(&filter) { + // Case-insensitive search; an explicit (?-i) in the filter still wins. + let re = match Regex::new(&format!("(?i){}", filter)) { Ok(re) => re, Err(e) => { out.e(format!("error: failed to parse re: {:?}", e)); diff --git a/hel/src/lk.rs b/hel/src/lk.rs index d1a10ab..3dd3fa9 100644 --- a/hel/src/lk.rs +++ b/hel/src/lk.rs @@ -12,6 +12,9 @@ pub struct LK { pub db: HashMap, pub ls: HashMap, pub secrets: HashMap, + /// Serialized dump as of the last load (`source`) or save (`dump`). Used to + /// show a `< removed` / `> added` diff on save so removals are noticed. + pub last_dump: Option, } impl LK { @@ -20,6 +23,7 @@ impl LK { db: HashMap::new(), ls: HashMap::new(), secrets: HashMap::new(), + last_dump: None, } } diff --git a/hel/src/parser.rs b/hel/src/parser.rs index ee838b5..085b1ed 100644 --- a/hel/src/parser.rs +++ b/hel/src/parser.rs @@ -9,7 +9,7 @@ peg::parser! { pub rule cmd() -> Command<'input> = c:(info_cmd_list() / mod_cmd_list() / enc_cmd_list() / asides_cmd_list()) { c } pub rule info_cmd_list() -> Command<'input> = space()* c:(ls_cmd() / ld_cmd() / pb_cmd() / save_cmd() / save_def_cmd() / dump_cmd()) { c } pub rule mod_cmd_list() -> Command<'input> = space()* c:(add_cmd() / keep_cmd() / mv_cmd() / rm_cmd() / comment_cmd ()) { c } - pub rule asides_cmd_list() -> Command<'input> = space()* c:(help_cmd() / source_cmd() / quit_cmd() / noop_cmd() / error_cmd()) { c } + pub rule asides_cmd_list() -> Command<'input> = space()* c:(help_cmd() / source_cmd() / set_cmd() / quit_cmd() / noop_cmd() / error_cmd()) { c } pub rule enc_cmd_list() -> Command<'input> = space()* c:(enc_cmd() / gen_cmd() / pass_cmd() / unpass_cmd() / correct_cmd() / uncorrect_cmd()) { c } pub rule script() -> Vec> = c:(info_cmd_list() / mod_cmd_list() / enc_cmd_list() / asides_cmd_list()) ++ "\n" { c } @@ -86,6 +86,7 @@ peg::parser! { rule save_def_cmd() -> Command<'input> = "save" { Command::Dump(None) } rule dump_cmd() -> Command<'input> = "dump" { Command::Dump(Some("-".to_string())) } rule source_cmd() -> Command<'input> = "source" _ s:$(([' '..='~'])+) { Command::Source(s.to_string()) } + rule set_cmd() -> Command<'input> = "set" _ k:word() _ v:$(([' '..='~'])+) { Command::Set(k, v.to_string()) } rule ls_cmd() -> Command<'input> = "ls" f:comment()? { Command::Ls(f.unwrap_or(".".to_string())) } rule ld_cmd() -> Command<'input> = "ld" f:comment()? { Command::Ld(f.unwrap_or(".".to_string())) } rule add_cmd() -> Command<'input> = "add" _ name:name() { Command::Add(Password::from_password(name)) } diff --git a/hel/src/repl.rs b/hel/src/repl.rs index c54486b..ea5af3b 100644 --- a/hel/src/repl.rs +++ b/hel/src/repl.rs @@ -130,6 +130,7 @@ impl<'a> LKEval<'a> { quit = self.cmd_source(&out, script); } Command::Dump(script) => self.cmd_dump(&out, script), + Command::Set(key, value) => { to_history = false; self.cmd_set(&out, key, value); } Command::Pass(name, None) => self.cmd_pass(&out, &name, &None), Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); }, Command::UnPass(name) => match self.state.lock().borrow_mut().secrets.remove(name) { diff --git a/hel/src/structs.rs b/hel/src/structs.rs index fadfcff..31761c7 100644 --- a/hel/src/structs.rs +++ b/hel/src/structs.rs @@ -3,6 +3,7 @@ use num_integer::Integer; use parking_lot::Mutex; use parking_lot::ReentrantMutex; use std::cell::RefCell; +use std::collections::HashMap; use std::fmt; use std::path::Path; use std::sync::Arc; @@ -44,6 +45,33 @@ lazy_static! { _ => home::dir().join(".hel_dump").into_boxed_path(), } }; + /// Runtime configuration set via the `set ` command (e.g. from + /// `~/.helrc`). Lets the user keep settings like `hel_dump` and + /// `hel_notion_token` in the init script instead of environment variables. + /// Keys are stored lowercase; `config_get` falls back to the uppercased + /// environment variable, and `config_envs` exports the map (uppercased) into + /// child processes spawned by the pipe/source plumbing. + pub static ref CONFIG: Mutex> = Mutex::new(HashMap::new()); +} + +/// Store a runtime config value under its lowercased key. +pub fn config_set(key: &str, value: &str) { + CONFIG.lock().insert(key.to_lowercase(), value.to_string()); +} + +/// Look up a config value: runtime `set` map first, then the uppercased env var. +pub fn config_get(key: &str) -> Option { + if let Some(v) = CONFIG.lock().get(&key.to_lowercase()) { + return Some(v.clone()); + } + std::env::var(key.to_uppercase()).ok() +} + +/// The runtime config as `(UPPERCASE_KEY, value)` pairs, for injection into the +/// environment of child processes (so `set hel_notion_token …` reaches a spawned +/// `hel store`/`hel load`). +pub fn config_envs() -> Vec<(String, String)> { + CONFIG.lock().iter().map(|(k, v)| (k.to_uppercase(), v.clone())).collect() } #[derive(thiserror::Error, Debug, PartialEq)] @@ -75,6 +103,7 @@ pub enum Command<'a> { PasteBuffer(String), Source(String), Dump(Option), + Set(String, String), Comment(Name, Comment), Error(LKErr<'a>), Noop, @@ -100,6 +129,7 @@ impl<'a> PartialEq for Command<'a> { (Command::PasteBuffer(s), Command::PasteBuffer(o)) => s == o, (Command::Source(s), Command::Source(o)) => s == o, (Command::Dump(s), Command::Dump(o)) => s == o, + (Command::Set(a, b), Command::Set(x, y)) => a == x && b == y, (Command::Comment(a, b), Command::Comment(x, y)) => a == x && b == y, (Command::Error(s), Command::Error(o)) => s == o, (Command::Noop, Command::Noop) => true, @@ -130,6 +160,9 @@ impl<'a> std::fmt::Display for Command<'a> { Command::Source(s) => write!(f, "source {}", s), Command::Dump(None) => write!(f, "dump"), Command::Dump(Some(s)) => write!(f, "dump {}", s), + // Value redacted: a `set` may carry a secret (e.g. hel_notion_token) + // and this Display feeds the history entry. + Command::Set(a, _) => write!(f, "set {} ***", a), Command::Comment(a, None) => write!(f, "comment {}", a), Command::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b), Command::Error(s) => write!(f, "error {}", s), diff --git a/hel/src/utils.rs b/hel/src/utils.rs index 2c1c668..f63e5f8 100644 --- a/hel/src/utils.rs +++ b/hel/src/utils.rs @@ -206,6 +206,9 @@ pub mod editor { pub fn call_cmd_with_input(cmd: &str, args: &Vec, input: &str) -> io::Result { let mut cmd = Command::new(cmd) .args(args) + // Export runtime `set …` config (uppercased) into the child, so e.g. + // `set hel_notion_token …` reaches a spawned `hel store`/`hel load`. + .envs(crate::structs::config_envs()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn()?; diff --git a/helcli/Cargo.toml b/helcli/Cargo.toml index 2ab0085..1947bad 100644 --- a/helcli/Cargo.toml +++ b/helcli/Cargo.toml @@ -12,3 +12,5 @@ name = "hel" [dependencies] hel = { version = "0.1.0", path = "../hel" } rustyline = "10.0.0" +ureq = { version = "2", features = ["json"] } # blocking HTTP, rustls default — no openssl +serde_json = "1" diff --git a/helcli/src/bin/hel.rs b/helcli/src/bin/hel.rs index afd9424..ce7d24f 100644 --- a/helcli/src/bin/hel.rs +++ b/helcli/src/bin/hel.rs @@ -3,7 +3,27 @@ extern crate hel; use hel::structs::init; pub fn main() { - let mut lkread = match init() { Some(r) => r, None => { return; } }; + // Non-interactive subcommands. These must short-circuit BEFORE init(): they + // do not start the REPL, read ~/.helrc, or prompt for a master password. + // That also prevents recursion — a `.helrc` line `source hel load notion:x|` + // spawns `hel load …`, which lands here and never re-reads `.helrc`. + let args: Vec = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("store") => { + std::process::exit(helcli::run_store(args.get(2).map(String::as_str).unwrap_or(""))) + } + Some("load") => { + std::process::exit(helcli::run_load(args.get(2).map(String::as_str).unwrap_or(""))) + } + _ => {} + } + + let mut lkread = match init() { + Some(r) => r, + None => { + return; + } + }; while lkread.read().eval().print() { lkread.refresh(); diff --git a/helcli/src/lib.rs b/helcli/src/lib.rs new file mode 100644 index 0000000..a875f04 --- /dev/null +++ b/helcli/src/lib.rs @@ -0,0 +1,88 @@ +//! `helcli` library: the non-interactive `hel store` / `hel load` subcommands +//! that back the `hel` REPL's persistence onto a Notion page. +//! +//! Contract (matches the old Evernote pipe scripts): +//! - `hel store notion:` reads the dump script from **stdin** and writes it +//! to the Notion page. Status/errors go to **stderr**; stdout is kept empty so +//! the parent `hel` prints its own "Passwords saved to command hel". +//! - `hel load notion:` fetches the page and writes the script to **stdout** +//! verbatim, for the parent's `source … |` to eval. + +pub mod notion; + +use std::io::Read; + +/// `hel store notion:` — stdin (dump) → Notion. Returns a process exit code. +pub fn run_store(target: &str) -> i32 { + let reference = match notion::parse_target(target) { + Ok(r) => r, + Err(e) => { + eprintln!("hel store: {}", e); + return 2; + } + }; + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + eprintln!("hel store: failed to read stdin: {}", e); + return 1; + } + let client = match notion::Notion::from_env() { + Ok(n) => n, + Err(e) => { + eprintln!("hel store: {}", e); + return 1; + } + }; + let page = match client.resolve_page(reference) { + Ok(p) => p, + Err(e) => { + eprintln!("hel store: {}", e); + return 1; + } + }; + match client.write_dump(&page, &input) { + Ok(()) => { + eprintln!("hel store: saved to Notion page {}", page); + 0 + } + Err(e) => { + eprintln!("hel store: {}", e); + 1 + } + } +} + +/// `hel load notion:` — Notion → stdout (dump). Returns a process exit code. +pub fn run_load(target: &str) -> i32 { + let reference = match notion::parse_target(target) { + Ok(r) => r, + Err(e) => { + eprintln!("hel load: {}", e); + return 2; + } + }; + let client = match notion::Notion::from_env() { + Ok(n) => n, + Err(e) => { + eprintln!("hel load: {}", e); + return 1; + } + }; + let page = match client.resolve_page(reference) { + Ok(p) => p, + Err(e) => { + eprintln!("hel load: {}", e); + return 1; + } + }; + match client.read_dump(&page) { + Ok(text) => { + print!("{}", text); + 0 + } + Err(e) => { + eprintln!("hel load: {}", e); + 1 + } + } +} diff --git a/helcli/src/notion.rs b/helcli/src/notion.rs new file mode 100644 index 0000000..5af3d00 --- /dev/null +++ b/helcli/src/notion.rs @@ -0,0 +1,277 @@ +//! Minimal Notion REST client for the `hel store` / `hel load` subcommands. +//! +//! The hel dump is a runnable `add …` script; this module parks the whole +//! script in a single **code block** on one Notion page and reads it back. The +//! page is addressed by `notion:` where `` is either a page id (UUID, +//! dashed or bare) or a page title resolved via Notion search. +//! +//! Auth: an internal-integration token from `HEL_NOTION_TOKEN`. The page must be +//! shared with that integration. + +use serde_json::{json, Value}; + +const API: &str = "https://api.notion.com/v1"; +const NOTION_VERSION: &str = "2022-06-28"; +/// Notion caps a single rich_text `content` at 2000 characters. +const MAX_RICH_TEXT: usize = 2000; + +/// Split `notion:` into its reference part, rejecting other schemes. +pub fn parse_target(target: &str) -> Result<&str, String> { + match target.split_once(':') { + Some(("notion", r)) if !r.is_empty() => Ok(r), + _ => Err(format!( + "unsupported target {:?}; expected notion:", + target + )), + } +} + +/// True if `s` is a 32-hex Notion page id (dashed UUID or bare hex). +pub fn is_uuid(s: &str) -> bool { + let hex: String = s.chars().filter(|c| *c != '-').collect(); + hex.len() == 32 && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Split `text` into `{type:text, text:{content}}` rich_text items, each at most +/// `MAX_RICH_TEXT` characters. Splits on char boundaries so multibyte stays intact. +pub fn chunk_rich_text(text: &str) -> Vec { + if text.is_empty() { + return vec![json!({ "type": "text", "text": { "content": "" } })]; + } + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < chars.len() { + let end = std::cmp::min(i + MAX_RICH_TEXT, chars.len()); + let chunk: String = chars[i..end].iter().collect(); + out.push(json!({ "type": "text", "text": { "content": chunk } })); + i = end; + } + out +} + +/// Plain text of a `code` block, concatenating its rich_text segments. +fn code_text(block: &Value) -> String { + block["code"]["rich_text"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|rt| rt["plain_text"].as_str()) + .collect::() + }) + .unwrap_or_default() +} + +/// Title of a page object: the value of its `title`-typed property, joined. +fn page_title(page: &Value) -> Option { + let props = page.get("properties")?.as_object()?; + for v in props.values() { + if v.get("type").and_then(Value::as_str) == Some("title") { + let arr = v.get("title")?.as_array()?; + return Some( + arr.iter() + .filter_map(|rt| rt.get("plain_text").and_then(Value::as_str)) + .collect(), + ); + } + } + None +} + +fn fmt_err(e: ureq::Error) -> String { + match e { + ureq::Error::Status(code, resp) => { + let body = resp.into_string().unwrap_or_default(); + format!("Notion API HTTP {}: {}", code, body.trim()) + } + ureq::Error::Transport(t) => format!("Notion request failed: {}", t), + } +} + +pub struct Notion { + token: String, +} + +impl Notion { + pub fn from_env() -> Result { + match std::env::var("HEL_NOTION_TOKEN") { + Ok(t) if !t.trim().is_empty() => Ok(Self { token: t }), + _ => Err("HEL_NOTION_TOKEN is not set (use `set hel_notion_token …` or export it)".to_string()), + } + } + + fn req(&self, method: &str, url: &str) -> ureq::Request { + ureq::request(method, url) + .set("Authorization", &format!("Bearer {}", self.token)) + .set("Notion-Version", NOTION_VERSION) + } + + /// Resolve `notion:` to a page id: a UUID is used directly; otherwise + /// search for a page whose title matches `reference` exactly. + pub fn resolve_page(&self, reference: &str) -> Result { + if is_uuid(reference) { + return Ok(reference.to_string()); + } + let resp: Value = self + .req("POST", &format!("{}/search", API)) + .send_json(json!({ + "query": reference, + "filter": { "value": "page", "property": "object" } + })) + .map_err(fmt_err)? + .into_json() + .map_err(|e| e.to_string())?; + + let empty = Vec::new(); + let results = resp["results"].as_array().unwrap_or(&empty); + // Match the title case-insensitively so `notion:hel` finds a page titled "HEL". + let want = reference.to_lowercase(); + let mut matches: Vec = results + .iter() + .filter(|p| page_title(p).map(|t| t.to_lowercase()).as_deref() == Some(want.as_str())) + .filter_map(|p| p["id"].as_str().map(str::to_string)) + .collect(); + + match matches.len() { + 0 => Err(format!( + "no Notion page titled {:?} is shared with the integration", + reference + )), + 1 => Ok(matches.remove(0)), + n => Err(format!( + "{} Notion pages titled {:?}; address it by page id instead", + n, reference + )), + } + } + + /// All child blocks of a page (following pagination). + fn children(&self, page_id: &str) -> Result, String> { + let mut blocks = Vec::new(); + let mut cursor: Option = None; + loop { + let mut url = format!("{}/blocks/{}/children?page_size=100", API, page_id); + if let Some(c) = &cursor { + url.push_str("&start_cursor="); + url.push_str(c); + } + let resp: Value = self + .req("GET", &url) + .call() + .map_err(fmt_err)? + .into_json() + .map_err(|e| e.to_string())?; + if let Some(results) = resp["results"].as_array() { + blocks.extend(results.iter().cloned()); + } + if resp["has_more"].as_bool() == Some(true) { + match resp["next_cursor"].as_str() { + Some(c) => cursor = Some(c.to_string()), + None => break, + } + } else { + break; + } + } + Ok(blocks) + } + + /// Read the dump script back: the text of the page's first code block. + pub fn read_dump(&self, page_id: &str) -> Result { + let children = self.children(page_id)?; + Ok(children + .iter() + .find(|b| b["type"].as_str() == Some("code")) + .map(code_text) + .unwrap_or_default()) + } + + /// Write the dump script: replace the page's first code block, or append a + /// fresh one if the page has none. + pub fn write_dump(&self, page_id: &str, text: &str) -> Result<(), String> { + let children = self.children(page_id)?; + let rich_text = chunk_rich_text(text); + + let existing = children + .iter() + .find(|b| b["type"].as_str() == Some("code")) + .and_then(|b| b["id"].as_str()); + + if let Some(block_id) = existing { + self.req("PATCH", &format!("{}/blocks/{}", API, block_id)) + .send_json(json!({ "code": { "rich_text": rich_text } })) + .map_err(fmt_err)?; + } else { + self.req("PATCH", &format!("{}/blocks/{}/children", API, page_id)) + .send_json(json!({ + "children": [{ + "type": "code", + "code": { "rich_text": rich_text, "language": "plain text" } + }] + })) + .map_err(fmt_err)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_target_ok() { + assert_eq!(parse_target("notion:hel"), Ok("hel")); + assert_eq!(parse_target("notion:My Page"), Ok("My Page")); + assert_eq!( + parse_target("notion:1234abcd1234abcd1234abcd1234abcd"), + Ok("1234abcd1234abcd1234abcd1234abcd") + ); + } + + #[test] + fn parse_target_err() { + assert!(parse_target("evernote:hel").is_err()); + assert!(parse_target("notion:").is_err()); + assert!(parse_target("hel").is_err()); + } + + #[test] + fn uuid_detection() { + assert!(is_uuid("11111111111111111111111111111111")); + assert!(is_uuid("11111111-1111-1111-1111-111111111111")); + assert!(!is_uuid("hel")); + assert!(!is_uuid("1111")); // too short + assert!(!is_uuid("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")); // non-hex + } + + #[test] + fn chunk_small_is_single() { + let v = chunk_rich_text("add foo R 0 2026-01-01"); + assert_eq!(v.len(), 1); + assert_eq!(v[0]["text"]["content"], "add foo R 0 2026-01-01"); + } + + #[test] + fn chunk_large_splits_under_limit() { + let big = "x".repeat(MAX_RICH_TEXT * 2 + 5); + let v = chunk_rich_text(&big); + assert_eq!(v.len(), 3); + for item in &v { + let len = item["text"]["content"].as_str().unwrap().chars().count(); + assert!(len <= MAX_RICH_TEXT); + } + let joined: String = v + .iter() + .map(|i| i["text"]["content"].as_str().unwrap()) + .collect(); + assert_eq!(joined, big); + } + + #[test] + fn chunk_empty() { + let v = chunk_rich_text(""); + assert_eq!(v.len(), 1); + assert_eq!(v[0]["text"]["content"], ""); + } +}