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:<ref>` reads the dump from stdin and writes it into a single code block on the page; `hel load notion:<ref>` prints it back. `<ref>` 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 <key> <value>`: 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 <target>`. - `save` prints a `< removed` / `> added` diff against the last load/save snapshot so removals are noticed before they persist.
This commit is contained in:
@@ -1,3 +1,63 @@
|
|||||||
# Hel: password generator and manager
|
# 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.
|
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 <regex>` matches **case-insensitively** (name, full line, and comment). Add
|
||||||
|
an inline `(?-i)` to the pattern to force case-sensitive.
|
||||||
|
- `source <file|command|>` echoes `source <target>` 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 <key> <value>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
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:<ref>` — read the dump from **stdin** and write it into the
|
||||||
|
Notion page (one code block, replaced in place).
|
||||||
|
- `hel load notion:<ref>` — fetch the page and print the dump to **stdout**.
|
||||||
|
|
||||||
|
`<ref>` 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.)
|
||||||
|
|||||||
+59
-17
@@ -10,7 +10,7 @@ use crate::parser::command_parser;
|
|||||||
use crate::password::fix_password_recursion;
|
use crate::password::fix_password_recursion;
|
||||||
use crate::password::{Name, Password, PasswordRef};
|
use crate::password::{Name, Password, PasswordRef};
|
||||||
use crate::repl::LKEval;
|
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::editor::password;
|
||||||
use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd};
|
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 {
|
pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool {
|
||||||
|
out.o(format!("source {}", source));
|
||||||
let script = if source.trim().ends_with("|") {
|
let script = if source.trim().ends_with("|") {
|
||||||
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
|
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -272,15 +273,54 @@ impl<'a> LKEval<'a> {
|
|||||||
out.e(format!("error: {}", e.to_string()));
|
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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All entries serialized as sorted `add …` lines (the dump format).
|
||||||
|
fn serialize_db(&self) -> String {
|
||||||
|
let mut vals: Vec<PasswordRef> = 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::<Vec<String>>()
|
||||||
|
.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<String>) {
|
pub fn cmd_dump(&self, out: &LKOut, script: &Option<String>) {
|
||||||
let script = match script {
|
// Default dump target: `set hel_dump …` > $HEL_DUMP > ~/.hel_dump.
|
||||||
Some(p) => p,
|
let script: String = match script {
|
||||||
None => DUMP_FILE.to_str().unwrap(),
|
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<Name, PasswordRef>, script: &String) -> std::io::Result<()> {
|
fn save_dump(data: &HashMap<Name, PasswordRef>, script: &String) -> std::io::Result<()> {
|
||||||
let file = fs::File::create(script)?;
|
let file = fs::File::create(script)?;
|
||||||
let mut writer = BufWriter::new(file);
|
let mut writer = BufWriter::new(file);
|
||||||
@@ -299,15 +339,8 @@ impl<'a> LKEval<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let data = self
|
let data = self.serialize_db();
|
||||||
.state
|
self.show_dump_diff(out, &data);
|
||||||
.lock()
|
|
||||||
.borrow()
|
|
||||||
.db
|
|
||||||
.values()
|
|
||||||
.map(|v| format!("add {}", v.lock().borrow().to_string()))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join("\n");
|
|
||||||
let output = match call_cmd_with_input(&cmd, &args, data.as_str()) {
|
let output = match call_cmd_with_input(&cmd, &args, data.as_str()) {
|
||||||
Ok(o) => o,
|
Ok(o) => o,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -315,6 +348,7 @@ impl<'a> LKEval<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.state.lock().borrow_mut().last_dump = Some(data);
|
||||||
if output.len() > 0 {
|
if output.len() > 0 {
|
||||||
out.e(format!("Passwords saved to command {} and got following output:", cmd));
|
out.e(format!("Passwords saved to command {} and got following output:", cmd));
|
||||||
out.o(output);
|
out.o(output);
|
||||||
@@ -328,8 +362,15 @@ impl<'a> LKEval<'a> {
|
|||||||
out.o(format!("add {}", pwd.lock().borrow().to_string()))
|
out.o(format!("add {}", pwd.lock().borrow().to_string()))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match save_dump(&self.state.lock().borrow().db, &script) {
|
let data = self.serialize_db();
|
||||||
Ok(()) => out.o(format!("Passwords saved to file {}", script)),
|
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())),
|
Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -339,7 +380,8 @@ impl<'a> LKEval<'a> {
|
|||||||
where
|
where
|
||||||
F: Fn(&PasswordRef, &PasswordRef) -> std::cmp::Ordering,
|
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,
|
Ok(re) => re,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
out.e(format!("error: failed to parse re: {:?}", e));
|
out.e(format!("error: failed to parse re: {:?}", e));
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ pub struct LK {
|
|||||||
pub db: HashMap<Name, PasswordRef>,
|
pub db: HashMap<Name, PasswordRef>,
|
||||||
pub ls: HashMap<String, PasswordRef>,
|
pub ls: HashMap<String, PasswordRef>,
|
||||||
pub secrets: HashMap<Name, String>,
|
pub secrets: HashMap<Name, String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LK {
|
impl LK {
|
||||||
@@ -20,6 +23,7 @@ impl LK {
|
|||||||
db: HashMap::new(),
|
db: HashMap::new(),
|
||||||
ls: HashMap::new(),
|
ls: HashMap::new(),
|
||||||
secrets: HashMap::new(),
|
secrets: HashMap::new(),
|
||||||
|
last_dump: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -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 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 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 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 enc_cmd_list() -> Command<'input> = space()* c:(enc_cmd() / gen_cmd() / pass_cmd() / unpass_cmd() / correct_cmd() / uncorrect_cmd()) { c }
|
||||||
pub rule script() -> Vec<Command<'input>> = c:(info_cmd_list() / mod_cmd_list() / enc_cmd_list() / asides_cmd_list()) ++ "\n" { c }
|
pub rule script() -> Vec<Command<'input>> = 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 save_def_cmd() -> Command<'input> = "save" { Command::Dump(None) }
|
||||||
rule dump_cmd() -> Command<'input> = "dump" { Command::Dump(Some("-".to_string())) }
|
rule dump_cmd() -> Command<'input> = "dump" { Command::Dump(Some("-".to_string())) }
|
||||||
rule source_cmd() -> Command<'input> = "source" _ s:$(([' '..='~'])+) { Command::Source(s.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 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 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)) }
|
rule add_cmd() -> Command<'input> = "add" _ name:name() { Command::Add(Password::from_password(name)) }
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ impl<'a> LKEval<'a> {
|
|||||||
quit = self.cmd_source(&out, script);
|
quit = self.cmd_source(&out, script);
|
||||||
}
|
}
|
||||||
Command::Dump(script) => self.cmd_dump(&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, None) => self.cmd_pass(&out, &name, &None),
|
||||||
Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); },
|
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) {
|
Command::UnPass(name) => match self.state.lock().borrow_mut().secrets.remove(name) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use num_integer::Integer;
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use parking_lot::ReentrantMutex;
|
use parking_lot::ReentrantMutex;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -44,6 +45,33 @@ lazy_static! {
|
|||||||
_ => home::dir().join(".hel_dump").into_boxed_path(),
|
_ => home::dir().join(".hel_dump").into_boxed_path(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/// Runtime configuration set via the `set <key> <value>` 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<HashMap<String, String>> = 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<String> {
|
||||||
|
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)]
|
#[derive(thiserror::Error, Debug, PartialEq)]
|
||||||
@@ -75,6 +103,7 @@ pub enum Command<'a> {
|
|||||||
PasteBuffer(String),
|
PasteBuffer(String),
|
||||||
Source(String),
|
Source(String),
|
||||||
Dump(Option<String>),
|
Dump(Option<String>),
|
||||||
|
Set(String, String),
|
||||||
Comment(Name, Comment),
|
Comment(Name, Comment),
|
||||||
Error(LKErr<'a>),
|
Error(LKErr<'a>),
|
||||||
Noop,
|
Noop,
|
||||||
@@ -100,6 +129,7 @@ impl<'a> PartialEq for Command<'a> {
|
|||||||
(Command::PasteBuffer(s), Command::PasteBuffer(o)) => s == o,
|
(Command::PasteBuffer(s), Command::PasteBuffer(o)) => s == o,
|
||||||
(Command::Source(s), Command::Source(o)) => s == o,
|
(Command::Source(s), Command::Source(o)) => s == o,
|
||||||
(Command::Dump(s), Command::Dump(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::Comment(a, b), Command::Comment(x, y)) => a == x && b == y,
|
||||||
(Command::Error(s), Command::Error(o)) => s == o,
|
(Command::Error(s), Command::Error(o)) => s == o,
|
||||||
(Command::Noop, Command::Noop) => true,
|
(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::Source(s) => write!(f, "source {}", s),
|
||||||
Command::Dump(None) => write!(f, "dump"),
|
Command::Dump(None) => write!(f, "dump"),
|
||||||
Command::Dump(Some(s)) => write!(f, "dump {}", s),
|
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, None) => write!(f, "comment {}", a),
|
||||||
Command::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b),
|
Command::Comment(a, Some(b)) => write!(f, "comment {} {}", a, b),
|
||||||
Command::Error(s) => write!(f, "error {}", s),
|
Command::Error(s) => write!(f, "error {}", s),
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ pub mod editor {
|
|||||||
pub fn call_cmd_with_input(cmd: &str, args: &Vec<String>, input: &str) -> io::Result<String> {
|
pub fn call_cmd_with_input(cmd: &str, args: &Vec<String>, input: &str) -> io::Result<String> {
|
||||||
let mut cmd = Command::new(cmd)
|
let mut cmd = Command::new(cmd)
|
||||||
.args(args)
|
.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())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ name = "hel"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
hel = { version = "0.1.0", path = "../hel" }
|
hel = { version = "0.1.0", path = "../hel" }
|
||||||
rustyline = "10.0.0"
|
rustyline = "10.0.0"
|
||||||
|
ureq = { version = "2", features = ["json"] } # blocking HTTP, rustls default — no openssl
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
+21
-1
@@ -3,7 +3,27 @@ extern crate hel;
|
|||||||
use hel::structs::init;
|
use hel::structs::init;
|
||||||
|
|
||||||
pub fn main() {
|
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<String> = 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() {
|
while lkread.read().eval().print() {
|
||||||
lkread.refresh();
|
lkread.refresh();
|
||||||
|
|||||||
@@ -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:<ref>` 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:<ref>` 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:<ref>` — 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:<ref>` — 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:<ref>` where `<ref>` 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:<ref>` 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:<page-title-or-id>",
|
||||||
|
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<Value> {
|
||||||
|
if text.is_empty() {
|
||||||
|
return vec![json!({ "type": "text", "text": { "content": "" } })];
|
||||||
|
}
|
||||||
|
let chars: Vec<char> = 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::<String>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title of a page object: the value of its `title`-typed property, joined.
|
||||||
|
fn page_title(page: &Value) -> Option<String> {
|
||||||
|
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<Self, String> {
|
||||||
|
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:<ref>` 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<String, String> {
|
||||||
|
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<String> = 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<Vec<Value>, String> {
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut cursor: Option<String> = 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<String, String> {
|
||||||
|
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"], "");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user