use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BearConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub api_token: Option<String>,
#[serde(default)]
pub default_tags: Vec<String>,
}
fn default_true() -> bool {
true
}
impl Default for BearConfig {
fn default() -> Self {
Self {
enabled: true,
api_token: None,
default_tags: Vec::new(),
}
}
}
impl BearConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
pub fn resolve_env_vars(&mut self) {
if let Ok(token) = std::env::var("BEAR_API_TOKEN") {
self.api_token = Some(token);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BearNote {
pub identifier: String,
pub title: String,
#[serde(default)]
pub tags: Vec<String>,
pub is_trashed: bool,
pub is_encrypted: bool,
pub creation_date: Option<String>,
pub modification_date: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CallbackResult {
pub url: String,
pub opened: bool,
pub identifier: Option<String>,
}
pub struct BearClient {
config: BearConfig,
}
impl BearClient {
const URL_SCHEME: &'static str = "bear://x-callback-url/";
pub fn new(config: &BearConfig) -> Result<Self> {
let mut config = config.clone();
config.resolve_env_vars();
Ok(Self { config })
}
pub fn is_configured(&self) -> bool {
self.config.enabled
}
pub async fn create_note(&self, title: &str, text: &str) -> Result<CallbackResult> {
let mut params = vec![
format!("title={}", urlencoding::encode(title)),
format!("text={}", urlencoding::encode(text)),
];
if !self.config.default_tags.is_empty() {
let tags = self.config.default_tags.join(",");
params.push(format!("tags={}", urlencoding::encode(&tags)));
}
params.push("open_note=no".to_string());
params.push("new_window=no".to_string());
let url = format!("{}create?{}", Self::URL_SCHEME, params.join("&"));
self.open_url(&url).await
}
pub async fn create_note_with_options(
&self,
title: &str,
text: &str,
tags: Option<Vec<&str>>,
pin: bool,
open_note: bool,
) -> Result<CallbackResult> {
let mut params = vec![
format!("title={}", urlencoding::encode(title)),
format!("text={}", urlencoding::encode(text)),
];
if let Some(t) = tags {
let tag_list = t.join(",");
params.push(format!("tags={}", urlencoding::encode(&tag_list)));
} else if !self.config.default_tags.is_empty() {
let tags = self.config.default_tags.join(",");
params.push(format!("tags={}", urlencoding::encode(&tags)));
}
if pin {
params.push("pin=yes".to_string());
}
if open_note {
params.push("open_note=yes".to_string());
} else {
params.push("open_note=no".to_string());
}
let url = format!("{}create?{}", Self::URL_SCHEME, params.join("&"));
self.open_url(&url).await
}
pub async fn open_note(&self, id: &str) -> Result<CallbackResult> {
let url = format!(
"{}open-note?id={}&new_window=no",
Self::URL_SCHEME,
urlencoding::encode(id)
);
self.open_url(&url).await
}
pub async fn open_note_by_title(&self, title: &str) -> Result<CallbackResult> {
let url = format!(
"{}open-note?title={}&new_window=no",
Self::URL_SCHEME,
urlencoding::encode(title)
);
self.open_url(&url).await
}
pub async fn add_text(&self, id: &str, text: &str, mode: AddTextMode) -> Result<CallbackResult> {
let mode_str = match mode {
AddTextMode::Append => "append",
AddTextMode::Prepend => "prepend",
AddTextMode::PrependAll => "prepend_all",
AddTextMode::Replace => "replace",
AddTextMode::ReplaceAll => "replace_all",
};
let url = format!(
"{}add-text?id={}&text={}&mode={}",
Self::URL_SCHEME,
urlencoding::encode(id),
urlencoding::encode(text),
mode_str
);
self.open_url(&url).await
}
pub async fn add_to_today(&self, text: &str) -> Result<CallbackResult> {
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let title = format!("Daily Note {}", today);
let url = format!(
"{}add-text?title={}&text={}&mode=append",
Self::URL_SCHEME,
urlencoding::encode(&title),
urlencoding::encode(text)
);
self.open_url(&url).await
}
pub async fn search(&self, term: &str) -> Result<CallbackResult> {
let url = format!(
"{}search?term={}&show_window=yes",
Self::URL_SCHEME,
urlencoding::encode(term)
);
self.open_url(&url).await
}
pub async fn search_by_tag(&self, tag: &str) -> Result<CallbackResult> {
let url = format!(
"{}open-tag?name={}&new_window=no",
Self::URL_SCHEME,
urlencoding::encode(tag)
);
self.open_url(&url).await
}
pub async fn grab_url(&self, url: &str, tags: Option<Vec<&str>>) -> Result<CallbackResult> {
let mut params = vec![
format!("url={}", urlencoding::encode(url)),
];
if let Some(t) = tags {
let tag_list = t.join(",");
params.push(format!("tags={}", urlencoding::encode(&tag_list)));
}
let callback_url = format!("{}grab-url?{}", Self::URL_SCHEME, params.join("&"));
self.open_url(&callback_url).await
}
pub async fn trash_note(&self, id: &str) -> Result<CallbackResult> {
let url = format!(
"{}trash?id={}",
Self::URL_SCHEME,
urlencoding::encode(id)
);
self.open_url(&url).await
}
pub async fn archive_note(&self, id: &str) -> Result<CallbackResult> {
let url = format!(
"{}archive?id={}",
Self::URL_SCHEME,
urlencoding::encode(id)
);
self.open_url(&url).await
}
pub async fn add_tags(&self, id: &str, tags: Vec<&str>) -> Result<CallbackResult> {
let url = format!(
"{}add-text?id={}&tags={}&mode=append&text=",
Self::URL_SCHEME,
urlencoding::encode(id),
urlencoding::encode(&tags.join(","))
);
self.open_url(&url).await
}
pub async fn change_font(&self, font: BearFont) -> Result<CallbackResult> {
let font_str = match font {
BearFont::Avenir => "Avenir Next",
BearFont::System => "System",
BearFont::Courier => "Courier Prime",
BearFont::Menlo => "Menlo",
BearFont::Georgia => "Georgia",
BearFont::SourceSansPro => "Source Sans Pro",
BearFont::Charter => "Charter",
};
let url = format!(
"{}change-font?font={}",
Self::URL_SCHEME,
urlencoding::encode(font_str)
);
self.open_url(&url).await
}
pub async fn change_theme(&self, theme: BearTheme) -> Result<CallbackResult> {
let theme_str = match theme {
BearTheme::RedGraphite => "Red Graphite",
BearTheme::Charcoal => "Charcoal",
BearTheme::Solarized => "Solarized Light",
BearTheme::Toothpaste => "Toothpaste",
BearTheme::Dracula => "Dracula",
BearTheme::Panic => "Panic Mode",
BearTheme::HighContrast => "High Contrast",
};
let url = format!(
"{}change-theme?theme={}",
Self::URL_SCHEME,
urlencoding::encode(theme_str)
);
self.open_url(&url).await
}
pub async fn open(&self) -> Result<CallbackResult> {
self.open_url("bear://").await
}
pub async fn open_today(&self) -> Result<CallbackResult> {
let url = format!("{}today", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn open_untagged(&self) -> Result<CallbackResult> {
let url = format!("{}untagged", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn open_locked(&self) -> Result<CallbackResult> {
let url = format!("{}locked", Self::URL_SCHEME);
self.open_url(&url).await
}
pub async fn open_trash(&self) -> Result<CallbackResult> {
let url = format!("{}trash", Self::URL_SCHEME);
self.open_url(&url).await
}
async fn open_url(&self, url: &str) -> Result<CallbackResult> {
#[cfg(target_os = "macos")]
{
use tokio::process::Command;
let status = Command::new("open")
.arg(url)
.status()
.await
.map_err(|e| DrivenError::Process(format!("Failed to open URL: {}", e)))?;
Ok(CallbackResult {
url: url.to_string(),
opened: status.success(),
identifier: None,
})
}
#[cfg(not(target_os = "macos"))]
{
Ok(CallbackResult {
url: url.to_string(),
opened: false,
identifier: None,
})
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddTextMode {
Append,
Prepend,
PrependAll,
Replace,
ReplaceAll,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BearFont {
Avenir,
System,
Courier,
Menlo,
Georgia,
SourceSansPro,
Charter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BearTheme {
RedGraphite,
Charcoal,
Solarized,
Toothpaste,
Dracula,
Panic,
HighContrast,
}
pub fn create_url(title: &str, text: &str) -> String {
format!(
"bear://x-callback-url/create?title={}&text={}",
urlencoding::encode(title),
urlencoding::encode(text)
)
}
pub fn search_url(term: &str) -> String {
format!(
"bear://x-callback-url/search?term={}",
urlencoding::encode(term)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = BearConfig::default();
assert!(config.enabled);
assert!(config.default_tags.is_empty());
}
#[test]
fn test_create_url() {
let url = create_url("Test Note", "Some content");
assert!(url.contains("bear://x-callback-url/create"));
assert!(url.contains("title=Test%20Note"));
}
#[test]
fn test_search_url() {
let url = search_url("keyword");
assert!(url.contains("bear://x-callback-url/search"));
assert!(url.contains("term=keyword"));
}
}