mod args;
mod config;
mod images;
mod reader;
mod store;
mod sync_client;
mod todo_tui;
use anyhow::{anyhow, bail, Context, Result};
use args::{
joined_text, Cli, Command as CliCommand, ConfigArgs, DeinitArgs, DeleteArgs, ExportArgs,
InitArgs, JournalCommand, JournalPasswordCommand, ListArgs, NewArgs, SearchArgs, TodoArgs,
};
use chrono::Utc;
use clap::Parser;
use config::{load_config, normalize_server_url, note_home, save_config, Config, SyncConfig};
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
use note_to_self_lib::model::normalize_tag;
use note_to_self_lib::{derive_journal_key, derive_keys, Entry, EntryKind};
use std::env;
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::Path;
use std::process::Command as ProcessCommand;
use store::{create_journal, delete_journal, list_journals, LocalStore};
use store::{
is_journal_locked, lock_journal_metadata, locked_journal_key, locked_journal_verifier,
unlock_journal_metadata, write_locked_journal_metadata,
write_locked_journal_metadata_with_verifier,
};
const DEFAULT_SERVER_URL: &str = "https://notes.nathom.dev";
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("error: {error:#}");
std::process::exit(1);
}
}
async fn run() -> Result<()> {
let cli = Cli::parse();
let root = note_home()?;
match &cli.command {
Some(CliCommand::Init(args)) => return init(&root, args).await,
Some(CliCommand::Deinit(args)) => return deinit(&root, args),
Some(CliCommand::Config(args)) => return config_command(&root, args),
Some(CliCommand::Login(args)) => return login(&root, args).await,
_ => {}
}
if cli.command.is_none() && cli.text.is_empty() && load_config(&root)?.is_none() {
ensure_configured(&root).await?;
return Ok(());
}
let mut config = ensure_configured(&root).await?;
let journal = cli
.journal
.clone()
.unwrap_or_else(|| config.default_journal.clone());
if let Some(CliCommand::Journal { command }) = &cli.command {
return journal_command(&root, &mut config, command).await;
}
if !cli.images.is_empty() && !matches!(&cli.command, None | Some(CliCommand::New(_))) {
bail!("--img can only be used when creating a new entry");
}
let username = ensure_username(&root, &mut config, None)?;
let keys = account_keys(root.as_path(), &mut config, &username)?;
if let Err(error) = refresh_remote_journals(&root, &config, &keys).await {
eprintln!("sync skipped during journal metadata refresh: {error:#}");
}
let encryption_key = journal_encryption_key(&root, &journal, &username, &keys.encryption_key)?;
let mut store = LocalStore::open(&root, &journal, &config, &encryption_key)?;
let creates_journal_entry = match &cli.command {
None => true,
Some(CliCommand::New(_)) => true,
_ => false,
};
if !creates_journal_entry {
best_effort_sync("pre-command", &mut store, &config, &keys).await;
}
let mutated = match &cli.command {
None => {
let attachments = images::attachments_from_paths(&cli.images)?;
let body = if cli.text.is_empty() && attachments.is_empty() {
edit_text(&config, "")?
} else {
images::body_with_images(joined_text(&cli.text), &attachments)
};
if body.trim().is_empty() {
return Ok(());
}
let id = store.add_journal_entry_with_attachments(body, &[], false, attachments)?;
println!("created {}", short(id));
if let Some(entry) = store.entries.get(&id) {
images::print_entry_images(entry)?;
}
true
}
Some(CliCommand::New(args)) => add_new(&mut store, &config, &cli.images, args)?,
Some(CliCommand::List(args)) => {
list_entries(&store, args);
false
}
Some(CliCommand::Edit { id }) => {
let entry = store.get(id)?;
let body = edit_text(&config, &entry.body)?;
let id = store.edit_body(id, body)?;
println!("updated {}", short(id));
true
}
Some(CliCommand::Delete(args)) => {
delete_entry(&mut store, args)?;
true
}
Some(CliCommand::Search(args)) => {
search_entries(&store, args);
false
}
Some(CliCommand::Read(args)) => reader::run(&mut store, args, &config, &keys, |initial| {
edit_text(&config, initial)
})?,
Some(CliCommand::Todo(args)) => todo_command(&mut store, &config, &keys, args)?,
Some(CliCommand::Export(args)) => {
export_entries(&store, args)?;
false
}
Some(CliCommand::Import { file }) => {
let raw = fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let count = store.import_json(&raw)?;
println!("imported {count} entries");
true
}
Some(CliCommand::Init(_))
| Some(CliCommand::Deinit(_))
| Some(CliCommand::Login(_))
| Some(CliCommand::Config(_))
| Some(CliCommand::Journal { .. }) => unreachable!("handled before store open"),
};
if mutated {
best_effort_sync("post-command", &mut store, &config, &keys).await;
}
Ok(())
}
fn deinit(root: &Path, args: &DeinitArgs) -> Result<()> {
if !root.exists() {
println!("not initialized; {} does not exist", root.display());
return Ok(());
}
ensure_safe_deinit_path(root)?;
if !args.yes
&& !Confirm::new()
.with_prompt(format!(
"Remove all local note-to-self data at {}? Remote data is not deleted.",
root.display()
))
.default(false)
.interact()?
{
println!("deinit cancelled");
return Ok(());
}
fs::remove_dir_all(root).with_context(|| format!("failed to remove {}", root.display()))?;
println!("removed {}", root.display());
Ok(())
}
fn ensure_safe_deinit_path(root: &Path) -> Result<()> {
let canonical = root
.canonicalize()
.with_context(|| format!("failed to resolve {}", root.display()))?;
if canonical.parent().is_none() {
bail!("refusing to remove filesystem root {}", canonical.display());
}
if let Some(home) = dirs::home_dir() {
if canonical == home {
bail!("refusing to remove home directory {}", canonical.display());
}
}
if canonical == std::env::current_dir()? {
bail!(
"refusing to remove current working directory {}",
canonical.display()
);
}
Ok(())
}
async fn init(root: &Path, args: &InitArgs) -> Result<()> {
if should_use_local_init(args) {
return init_local(root, args);
}
init_interactive(root, args).await
}
fn should_use_local_init(args: &InitArgs) -> bool {
args.local || (args.server.is_none() && (args.username.is_some() || args.journal.is_some()))
}
fn init_local(root: &Path, args: &InitArgs) -> Result<()> {
let username = match &args.username {
Some(username) => Some(username.clone()),
None => env::var("NOTE_TO_SELF_USERNAME")
.ok()
.or_else(|| env::var("JRNL2_USERNAME").ok())
.or_else(|| env::var("USER").ok()),
};
let journal = args
.journal
.clone()
.unwrap_or_else(|| "personal".to_string());
let config = Config::new(
username.clone(),
journal.clone(),
args.server.as_deref().map(normalize_server_url),
);
create_journal(root, &journal)?;
save_config(root, &config)?;
println!(
"initialized {} with default journal {}",
root.display(),
journal
);
if username.is_none() {
println!("username will be requested on first encrypted operation");
}
Ok(())
}
async fn init_interactive(root: &Path, args: &InitArgs) -> Result<()> {
let theme = ColorfulTheme::default();
let existing = load_config(root)?;
let default_server = default_server_url(
args.server.as_deref(),
existing.as_ref(),
env::var("NOTE_TO_SELF_SERVER").ok(),
);
let server_url = normalize_server_url(
&Input::<String>::with_theme(&theme)
.with_prompt("Sync server")
.default(default_server)
.interact_text()?,
);
let default_username = args
.username
.clone()
.or_else(|| existing.as_ref().and_then(|config| config.username.clone()))
.or_else(|| env::var("NOTE_TO_SELF_USERNAME").ok())
.or_else(|| env::var("JRNL2_USERNAME").ok())
.or_else(|| env::var("USER").ok())
.unwrap_or_else(|| "me".to_string());
let username = Input::<String>::with_theme(&theme)
.with_prompt("Username")
.default(default_username)
.interact_text()?;
let password = read_init_password(&username)?;
let keys = derive_keys(&username, &password)?;
match login_or_register_with_signup_confirmation(&server_url, &username, &keys, &password)
.await?
{
sync_client::AuthMode::LoggedIn => println!("logged in as {username}"),
sync_client::AuthMode::Registered => println!("registered {username}"),
}
let preferred_journal = args.journal.clone().or_else(|| {
existing
.as_ref()
.map(|config| config.default_journal.clone())
});
let mut config = Config::new(
Some(username.clone()),
"personal".to_string(),
Some(server_url.clone()),
);
if let Some(existing) = existing {
config.editor = existing.editor;
config.device_id = existing.device_id;
}
config.set_account_keys(&keys);
let mut journals = sync_client::list_remote_journal_infos(&config, &keys).await?;
journals.sort_by(|a, b| a.name.cmp(&b.name));
journals.dedup_by(|a, b| a.name == b.name);
if journals.is_empty() {
println!("no remote journals found; creating personal");
let locked = prompt_new_journal_locked("personal")?;
let verifier = if locked {
let password = read_new_journal_password("personal")?;
lock_journal_metadata(root, "personal", &username, &password)?;
locked_journal_verifier(root, "personal")?
} else {
None
};
sync_client::create_remote_journal(&config, &keys, "personal", locked, verifier.as_deref())
.await?;
journals.push(sync_client::RemoteJournal {
name: "personal".to_string(),
locked,
verifier,
});
}
let default_journal = choose_default_journal(
&theme,
&mut journals,
root,
&config,
&username,
&keys,
preferred_journal.as_deref(),
)
.await?;
config.default_journal = default_journal.clone();
for journal in &journals {
create_journal(root, &journal.name)?;
if journal.locked && !is_journal_locked(root, &journal.name)? {
if let Some(verifier) = &journal.verifier {
write_locked_journal_metadata_with_verifier(root, &journal.name, verifier.clone())?;
let password = read_existing_journal_password(&journal.name)?;
locked_journal_key(root, &journal.name, &username, &password)?;
} else {
let password = read_existing_journal_password(&journal.name)?;
lock_journal_metadata(root, &journal.name, &username, &password)?;
}
}
}
save_config(root, &config)?;
for journal in &journals {
let encryption_key =
journal_encryption_key(root, &journal.name, &username, &keys.encryption_key)?;
let mut store = LocalStore::open(root, &journal.name, &config, &encryption_key)?;
sync_client::sync_once(&mut store, &config, &keys)
.await
.with_context(|| format!("initial sync failed for journal {}", journal.name))?;
}
println!(
"initialized {} with {} journal(s); default is {}",
root.display(),
journals.len(),
default_journal
);
Ok(())
}
async fn choose_default_journal(
theme: &ColorfulTheme,
journals: &mut Vec<sync_client::RemoteJournal>,
root: &Path,
config: &Config,
username: &str,
keys: ¬e_to_self_lib::DerivedKeys,
preferred: Option<&str>,
) -> Result<String> {
let mut choices: Vec<String> = journals
.iter()
.map(|journal| {
if journal.locked {
format!("{} [locked]", journal.name)
} else {
journal.name.clone()
}
})
.collect();
choices.push("Create a new journal".to_string());
let default = preferred
.and_then(|preferred| {
journals
.iter()
.position(|journal| journal.name == preferred)
})
.unwrap_or(0);
let selected = Select::with_theme(theme)
.with_prompt("Default journal")
.items(&choices)
.default(default)
.interact()?;
if selected < journals.len() {
return Ok(journals[selected].name.clone());
}
let name = Input::<String>::with_theme(theme)
.with_prompt("New journal name")
.default("personal".to_string())
.interact_text()?;
let locked = prompt_new_journal_locked(&name)?;
let verifier = if locked {
let password = read_new_journal_password(&name)?;
lock_journal_metadata(root, &name, username, &password)?;
locked_journal_verifier(root, &name)?
} else {
None
};
sync_client::create_remote_journal(config, keys, &name, locked, verifier.as_deref()).await?;
journals.push(sync_client::RemoteJournal {
name: name.clone(),
locked,
verifier,
});
journals.sort_by(|a, b| a.name.cmp(&b.name));
journals.dedup_by(|a, b| a.name == b.name);
Ok(name)
}
fn read_init_password(username: &str) -> Result<String> {
if let Some(password) = account_password_from_env() {
return Ok(password);
}
Password::new()
.with_prompt(format!("Password for {username}"))
.interact()
.context("failed to read password")
}
async fn login_or_register_with_signup_confirmation(
server_url: &str,
username: &str,
keys: ¬e_to_self_lib::DerivedKeys,
password: &str,
) -> Result<sync_client::AuthMode> {
match sync_client::verify_auth_attempt(server_url, username, keys).await? {
sync_client::AuthAttempt::Success => return Ok(sync_client::AuthMode::LoggedIn),
sync_client::AuthAttempt::Unauthorized => {}
sync_client::AuthAttempt::Conflict => {
bail!("login failed because the account is in conflict")
}
sync_client::AuthAttempt::Failed { status, body } => {
bail!("login failed with {status}: {body}")
}
}
confirm_account_password_for_signup(username, password)?;
println!("user {username} not found; creating account");
match sync_client::register_auth_attempt(server_url, username, keys).await? {
sync_client::AuthAttempt::Success => Ok(sync_client::AuthMode::Registered),
sync_client::AuthAttempt::Conflict => {
bail!("account {username} already exists, but the password did not match")
}
sync_client::AuthAttempt::Unauthorized => bail!("registration was unauthorized"),
sync_client::AuthAttempt::Failed { status, body } => {
bail!("registration failed with {status}: {body}")
}
}
}
fn confirm_account_password_for_signup(username: &str, password: &str) -> Result<()> {
if account_password_from_env().is_some() {
return Ok(());
}
let confirmation = Password::new()
.with_prompt(format!("Confirm password for {username}"))
.interact()
.context("failed to read password confirmation")?;
if confirmation != password {
bail!("passwords did not match");
}
Ok(())
}
fn account_password_from_env() -> Option<String> {
env::var("NOTE_TO_SELF_PASSWORD")
.ok()
.or_else(|| env::var("JRNL2_PASSWORD").ok())
}
fn prompt_new_journal_locked(name: &str) -> Result<bool> {
if !std::io::stdin().is_terminal() {
return Ok(false);
}
Confirm::new()
.with_prompt(format!("Encrypt journal {name} with a separate password?"))
.default(false)
.interact()
.context("failed to read journal encryption choice")
}
async fn login(root: &Path, args: &args::AuthArgs) -> Result<()> {
let mut config = load_config(root)?.unwrap_or_else(|| {
Config::new(
args.username.clone(),
"personal".to_string(),
args.server.as_deref().map(normalize_server_url),
)
});
let username = ensure_username(root, &mut config, args.username.clone())?;
let server_url = args
.server
.as_deref()
.map(normalize_server_url)
.or_else(|| config.sync.as_ref().map(|sync| sync.server_url.clone()))
.unwrap_or_else(|| DEFAULT_SERVER_URL.to_string());
let password = read_password(&username)?;
let keys = derive_keys(&username, &password)?;
match login_or_register_with_signup_confirmation(&server_url, &username, &keys, &password)
.await?
{
sync_client::AuthMode::LoggedIn => println!("logged in as {username}"),
sync_client::AuthMode::Registered => println!("registered {username}"),
}
config.username = Some(username.clone());
config.set_account_keys(&keys);
config.sync = Some(SyncConfig {
server_url,
username,
});
save_config(root, &config)?;
let journals = refresh_remote_journals(root, &config, &keys).await?;
if !journals.is_empty()
&& !journals
.iter()
.any(|journal| journal.name == config.default_journal)
{
config.default_journal = journals[0].name.clone();
}
sync_unlocked_remote_journals(root, &config, &keys, &journals).await?;
save_config(root, &config)?;
Ok(())
}
fn config_command(root: &Path, args: &ConfigArgs) -> Result<()> {
if args.edit {
fs::create_dir_all(root)?;
let path = config::config_path(root);
if !path.exists() {
let config = Config::new(None, "personal".to_string(), None);
save_config(root, &config)?;
}
let editor = env::var("EDITOR").unwrap_or_else(|_| "nvim".to_string());
let status = ProcessCommand::new(editor)
.arg(&path)
.status()
.context("failed to launch editor")?;
if !status.success() {
bail!("editor exited unsuccessfully");
}
return Ok(());
}
match load_config(root)? {
Some(config) => println!("{}", toml::to_string_pretty(&config)?),
None => println!("not initialized"),
}
Ok(())
}
fn default_server_url(
args_server: Option<&str>,
existing: Option<&Config>,
env_server: Option<String>,
) -> String {
args_server
.map(str::to_string)
.or_else(|| {
existing
.and_then(|config| config.sync.as_ref())
.map(|sync| sync.server_url.clone())
})
.or(env_server)
.unwrap_or_else(|| DEFAULT_SERVER_URL.to_string())
}
async fn refresh_remote_journals(
root: &Path,
config: &Config,
keys: ¬e_to_self_lib::DerivedKeys,
) -> Result<Vec<sync_client::RemoteJournal>> {
let mut journals = sync_client::list_remote_journal_infos(config, keys).await?;
journals.sort_by(|a, b| a.name.cmp(&b.name));
journals.dedup_by(|a, b| a.name == b.name);
for journal in &journals {
apply_remote_journal_metadata(root, journal)?;
}
Ok(journals)
}
fn apply_remote_journal_metadata(root: &Path, journal: &sync_client::RemoteJournal) -> Result<()> {
create_journal(root, &journal.name)?;
if journal.locked {
if let Some(verifier) = &journal.verifier {
write_locked_journal_metadata_with_verifier(root, &journal.name, verifier.clone())?;
}
} else if is_journal_locked(root, &journal.name)? {
unlock_journal_metadata(root, &journal.name)?;
}
Ok(())
}
async fn sync_unlocked_remote_journals(
root: &Path,
config: &Config,
keys: ¬e_to_self_lib::DerivedKeys,
journals: &[sync_client::RemoteJournal],
) -> Result<()> {
for journal in journals {
if journal.locked {
continue;
}
let mut store = LocalStore::open(root, &journal.name, config, &keys.encryption_key)
.with_context(|| format!("failed to open journal {}", journal.name))?;
sync_client::sync_once(&mut store, config, keys)
.await
.with_context(|| format!("sync failed for journal {}", journal.name))?;
}
Ok(())
}
async fn journal_command(root: &Path, config: &mut Config, command: &JournalCommand) -> Result<()> {
match command {
JournalCommand::List => {
if let Some((_username, keys)) = maybe_keys(root, config)? {
if let Err(error) = refresh_remote_journals(root, config, &keys).await {
eprintln!("sync skipped: {error:#}");
}
}
let journals = list_journals(root)?;
for journal in journals {
let suffix = if is_journal_locked(root, &journal)? {
" [locked]"
} else {
""
};
if journal == config.default_journal {
println!("* {journal}{suffix}");
} else {
println!(" {journal}{suffix}");
}
}
}
JournalCommand::New {
name,
locked,
plain,
} => {
create_journal(root, name)?;
let locked = *locked || (!*plain && prompt_new_journal_locked(name)?);
let verifier = if locked {
let username = ensure_username(root, config, None)?;
let password = read_new_journal_password(name)?;
lock_journal_metadata(root, name, &username, &password)?;
locked_journal_verifier(root, name)?
} else {
None
};
if let Some((username, keys)) = maybe_keys(root, config)? {
if let Err(error) = sync_client::create_remote_journal(
config,
&keys,
name,
locked,
verifier.as_deref(),
)
.await
{
eprintln!("sync skipped: {error:#}");
}
drop(username);
}
if locked {
println!("created locked journal {name}");
} else {
println!("created journal {name}");
}
}
JournalCommand::Default { name } => {
if let Some(name) = name {
create_journal(root, name)?;
config.default_journal = name.clone();
save_config(root, config)?;
println!("default journal set to {name}");
} else {
println!("{}", config.default_journal);
}
}
JournalCommand::Lock { name } => {
add_journal_password(root, config, name).await?;
}
JournalCommand::Unlock { name } => {
remove_journal_password(root, config, name).await?;
}
JournalCommand::Password { command } => match command {
JournalPasswordCommand::Add { name } => {
add_journal_password(root, config, name).await?;
}
JournalPasswordCommand::Change { name } => {
change_journal_password(root, config, name).await?;
}
JournalPasswordCommand::Remove { name } => {
remove_journal_password(root, config, name).await?;
}
},
JournalCommand::Delete { name, yes } => {
if name == &config.default_journal {
bail!("cannot delete the default journal; set another default first");
}
if !*yes
&& !Confirm::new()
.with_prompt(format!("Delete journal {name}?"))
.default(false)
.interact()?
{
return Ok(());
}
delete_journal(root, name)?;
if let Some((username, keys)) = maybe_keys(root, config)? {
if let Err(error) = sync_client::delete_remote_journal(config, &keys, name).await {
eprintln!("sync skipped: {error:#}");
}
drop(username);
}
println!("deleted journal {name}");
}
}
Ok(())
}
async fn add_journal_password(root: &Path, config: &mut Config, name: &str) -> Result<()> {
if is_journal_locked(root, name)? {
bail!("journal {name} is already locked");
}
let username = ensure_username(root, config, None)?;
let keys = account_keys(root, config, &username)?;
let mut store = LocalStore::open(root, name, config, &keys.encryption_key)
.with_context(|| format!("failed to open journal {name} before locking"))?;
let journal_password = read_new_journal_password(name)?;
let journal_key = derive_journal_key(&username, name, &journal_password)?;
let count = store.reencrypt_all(&journal_key)?;
write_locked_journal_metadata(root, name, &journal_key)?;
let verifier = locked_journal_verifier(root, name)?;
sync_journal_password_state(
"lock",
&mut store,
config,
&keys,
name,
true,
verifier.as_deref(),
)
.await;
println!("locked journal {name}; re-encrypted {count} entries");
Ok(())
}
async fn change_journal_password(root: &Path, config: &mut Config, name: &str) -> Result<()> {
if !is_journal_locked(root, name)? {
bail!("journal {name} is not locked; use `note journal password add {name}` first");
}
let username = ensure_username(root, config, None)?;
let keys = account_keys(root, config, &username)?;
let old_password = read_existing_journal_password(name)?;
let old_key = locked_journal_key(root, name, &username, &old_password)?;
let mut store = LocalStore::open(root, name, config, &old_key)
.with_context(|| format!("failed to open locked journal {name}"))?;
let new_password = read_new_journal_password(name)?;
let new_key = derive_journal_key(&username, name, &new_password)?;
let count = store.reencrypt_all(&new_key)?;
write_locked_journal_metadata(root, name, &new_key)?;
let verifier = locked_journal_verifier(root, name)?;
sync_journal_password_state(
"password change",
&mut store,
config,
&keys,
name,
true,
verifier.as_deref(),
)
.await;
println!("changed password for journal {name}; re-encrypted {count} entries");
Ok(())
}
async fn remove_journal_password(root: &Path, config: &mut Config, name: &str) -> Result<()> {
if !is_journal_locked(root, name)? {
bail!("journal {name} is not locked");
}
let username = ensure_username(root, config, None)?;
let keys = account_keys(root, config, &username)?;
let journal_password = read_existing_journal_password(name)?;
let journal_key = locked_journal_key(root, name, &username, &journal_password)?;
let mut store = LocalStore::open(root, name, config, &journal_key)
.with_context(|| format!("failed to open locked journal {name}"))?;
let count = store.reencrypt_all(&keys.encryption_key)?;
unlock_journal_metadata(root, name)?;
sync_journal_password_state("unlock", &mut store, config, &keys, name, false, None).await;
println!("unlocked journal {name}; re-encrypted {count} entries");
Ok(())
}
async fn sync_journal_password_state(
phase: &str,
store: &mut LocalStore,
config: &Config,
keys: ¬e_to_self_lib::DerivedKeys,
name: &str,
locked: bool,
verifier: Option<&str>,
) {
if config.sync.is_none() {
return;
}
if let Err(error) = sync_client::sync_once(store, config, keys).await {
eprintln!("sync skipped during {phase}: {error:#}");
return;
}
if let Err(error) =
sync_client::update_remote_journal_metadata(config, keys, name, locked, verifier).await
{
eprintln!("sync skipped during {phase} metadata update: {error:#}");
}
}
fn add_new(
store: &mut LocalStore,
config: &Config,
global_images: &[std::path::PathBuf],
args: &NewArgs,
) -> Result<bool> {
let image_paths = global_images
.iter()
.chain(args.images.iter())
.cloned()
.collect::<Vec<_>>();
let attachments = images::attachments_from_paths(&image_paths)?;
let body = if args.text.is_empty() && attachments.is_empty() {
edit_text(config, "")?
} else {
images::body_with_images(joined_text(&args.text), &attachments)
};
if body.trim().is_empty() {
return Ok(false);
}
let id =
store.add_journal_entry_with_attachments(body, &args.tags, args.starred, attachments)?;
println!("created {}", short(id));
if let Some(entry) = store.entries.get(&id) {
images::print_entry_images(entry)?;
}
Ok(true)
}
fn delete_entry(store: &mut LocalStore, args: &DeleteArgs) -> Result<()> {
let entry = store.get(&args.id)?;
if !args.yes
&& !Confirm::new()
.with_prompt(format!("Delete entry {}?", entry.short_id()))
.default(false)
.interact()?
{
return Ok(());
}
let id = store.soft_delete(&args.id)?;
println!("deleted {}", short(id));
Ok(())
}
fn todo_command(
store: &mut LocalStore,
config: &Config,
keys: ¬e_to_self_lib::DerivedKeys,
args: &TodoArgs,
) -> Result<bool> {
if args.text.is_empty() {
return todo_tui::run(store, config, keys);
}
add_todo(store, args)?;
Ok(true)
}
fn add_todo(store: &mut LocalStore, args: &TodoArgs) -> Result<()> {
let body = joined_text(&args.text);
let id = store.add_todo(body, &args.tags, args.priority.into(), args.due)?;
println!("created todo {}", short(id));
Ok(())
}
fn list_entries(store: &LocalStore, args: &ListArgs) {
let tag = args.tag.as_deref().and_then(normalize_tag);
let today = Utc::now().date_naive();
let mut entries: Vec<&Entry> = store
.entries
.values()
.filter(|entry| !entry.deleted)
.filter(|entry| matches!(entry.kind, EntryKind::Journal))
.filter(|entry| !args.today || entry.created_at.date_naive() == today)
.filter(|entry| {
args.from
.map_or(true, |from| entry.created_at.date_naive() >= from)
})
.filter(|entry| {
args.to
.map_or(true, |to| entry.created_at.date_naive() <= to)
})
.filter(|entry| !args.starred || entry.starred)
.filter(|entry| {
tag.as_ref().map_or(true, |tag| {
entry.tags.iter().any(|entry_tag| entry_tag == tag)
})
})
.collect();
entries.sort_by_key(|entry| entry.created_at);
entries.reverse();
for entry in entries.into_iter().take(args.limit) {
println!("{}", format_entry_line(entry));
if let Err(error) = images::print_entry_images(entry) {
eprintln!("image preview failed: {error:#}");
}
}
}
fn search_entries(store: &LocalStore, args: &SearchArgs) {
let query = args.query.to_ascii_lowercase();
let mut entries: Vec<&Entry> = store
.entries
.values()
.filter(|entry| !entry.deleted)
.filter(|entry| {
entry.body.to_ascii_lowercase().contains(&query)
|| entry.tags.iter().any(|tag| tag.contains(&query))
})
.collect();
entries.sort_by_key(|entry| entry.created_at);
entries.reverse();
for entry in entries.into_iter().take(args.limit) {
println!("{}", format_entry_line(entry));
if let Err(error) = images::print_entry_images(entry) {
eprintln!("image preview failed: {error:#}");
}
}
}
fn format_entry_line(entry: &Entry) -> String {
let date = entry.created_at.format("%Y-%m-%d %H:%M UTC");
let tags = if entry.tags.is_empty() {
String::new()
} else {
format!(
" {}",
entry
.tags
.iter()
.map(|tag| format!("#{tag}"))
.collect::<Vec<_>>()
.join(" ")
)
};
match &entry.kind {
EntryKind::Journal => {
let star = if entry.starred { " *" } else { "" };
format!(
"{} {}{} {}{}",
entry.short_id(),
date,
star,
entry_one_line(entry),
tags
)
}
EntryKind::Todo(meta) => {
let check = if meta.completed { "[x]" } else { "[ ]" };
let due = meta
.due
.map(|due| format!(" due:{due}"))
.unwrap_or_default();
format!(
"{} {} {} p:{}{} {}{}",
entry.short_id(),
check,
date,
meta.priority,
due,
entry_one_line(entry),
tags
)
}
}
}
fn entry_one_line(entry: &Entry) -> String {
images::body_preview(entry, 100)
}
fn export_entries(store: &LocalStore, args: &ExportArgs) -> Result<()> {
if args.format != "json" {
bail!("only json export is currently supported");
}
let raw = store.export_json()?;
if let Some(path) = &args.output {
fs::write(path, raw).with_context(|| format!("failed to write {}", path.display()))?;
} else {
println!("{raw}");
}
Ok(())
}
async fn best_effort_sync(
phase: &str,
store: &mut LocalStore,
config: &Config,
keys: ¬e_to_self_lib::DerivedKeys,
) {
if config.sync.is_none() {
return;
}
if let Err(error) = sync_client::sync_once(store, config, keys).await {
eprintln!("sync skipped during {phase}: {error:#}");
}
}
async fn ensure_configured(root: &Path) -> Result<Config> {
if let Some(config) = load_config(root)? {
return Ok(config);
}
println!("note-to-self is not initialized; starting setup");
let args = InitArgs {
local: false,
server: None,
username: None,
journal: None,
};
init_interactive(root, &args).await?;
load_config(root)?.ok_or_else(|| anyhow!("init completed but no config was written"))
}
fn ensure_username(
root: &Path,
config: &mut Config,
override_username: Option<String>,
) -> Result<String> {
if let Some(username) = override_username {
config.username = Some(username.clone());
if let Some(sync) = &mut config.sync {
sync.username = username.clone();
}
save_config(root, config)?;
return Ok(username);
}
if let Some(username) = config.sync_username() {
return Ok(username.to_string());
}
let username = env::var("NOTE_TO_SELF_USERNAME")
.or_else(|_| env::var("JRNL2_USERNAME"))
.unwrap_or_else(|_| {
Input::<String>::new()
.with_prompt("Username")
.interact_text()
.unwrap_or_else(|_| "local".to_string())
});
config.username = Some(username.clone());
save_config(root, config)?;
Ok(username)
}
fn read_password(username: &str) -> Result<String> {
if let Some(password) = account_password_from_env() {
return Ok(password);
}
Password::new()
.with_prompt(format!("Password for {username}"))
.interact()
.context("failed to read password")
}
fn journal_encryption_key(
root: &Path,
journal: &str,
username: &str,
account_key: &[u8; 32],
) -> Result<[u8; 32]> {
if !is_journal_locked(root, journal)? {
return Ok(*account_key);
}
let password = read_existing_journal_password(journal)?;
locked_journal_key(root, journal, username, &password)
}
fn read_existing_journal_password(journal: &str) -> Result<String> {
read_journal_password(journal, false)
}
fn read_new_journal_password(journal: &str) -> Result<String> {
read_journal_password(journal, true)
}
fn read_journal_password(journal: &str, confirm: bool) -> Result<String> {
if let Some(password) = journal_password_from_env(journal) {
return Ok(password);
}
let prompt = format!("Password for locked journal {journal}");
let mut password = Password::new().with_prompt(prompt);
if confirm {
password =
password.with_confirmation("Confirm journal password", "passwords did not match");
}
password
.interact()
.context("failed to read journal password")
}
fn journal_password_from_env(journal: &str) -> Option<String> {
let specific = format!(
"NOTE_TO_SELF_JOURNAL_PASSWORD_{}",
journal
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>()
);
env::var(specific)
.ok()
.or_else(|| env::var("NOTE_TO_SELF_JOURNAL_PASSWORD").ok())
.or_else(|| env::var("NOTE_TO_SELF_LOCK_PASSWORD").ok())
}
fn maybe_keys(
root: &Path,
config: &mut Config,
) -> Result<Option<(String, note_to_self_lib::DerivedKeys)>> {
if config.sync.is_none() {
return Ok(None);
}
let username = ensure_username(root, config, None)?;
let keys = account_keys(root, config, &username)?;
Ok(Some((username, keys)))
}
fn account_keys(
root: &Path,
config: &mut Config,
username: &str,
) -> Result<note_to_self_lib::DerivedKeys> {
if let Some(keys) = config.cached_account_keys()? {
return Ok(keys);
}
let password = read_password(username)?;
let keys = derive_keys(username, &password)?;
config.set_account_keys(&keys);
save_config(root, config)?;
Ok(keys)
}
fn edit_text(config: &Config, initial: &str) -> Result<String> {
let editor = config
.editor
.clone()
.or_else(|| env::var("EDITOR").ok())
.unwrap_or_else(|| "nvim".to_string());
let mut file = tempfile::Builder::new()
.prefix("note-to-self-")
.suffix(".md")
.tempfile()
.context("failed to create temp file")?;
if !initial.is_empty() {
file.write_all(initial.as_bytes())?;
file.flush()?;
}
let status = ProcessCommand::new(editor)
.arg(file.path())
.status()
.context("failed to launch editor")?;
if !status.success() {
bail!("editor exited unsuccessfully");
}
fs::read_to_string(file.path()).context("failed to read edited entry")
}
fn short(id: uuid::Uuid) -> String {
id.to_string().chars().take(8).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_server_url_uses_public_notes_host_without_overrides() {
assert_eq!(default_server_url(None, None, None), DEFAULT_SERVER_URL);
}
#[test]
fn default_server_url_prefers_explicit_sources() {
let config = Config::new(
Some("alice".to_string()),
"personal".to_string(),
Some("https://configured.example".to_string()),
);
assert_eq!(
default_server_url(Some("https://cli.example"), Some(&config), None),
"https://cli.example"
);
assert_eq!(
default_server_url(None, Some(&config), Some("https://env.example".to_string())),
"https://configured.example"
);
assert_eq!(
default_server_url(None, None, Some("https://env.example".to_string())),
"https://env.example"
);
}
}