main
Oliver Kennedy 2023-12-28 16:24:42 -05:00
parent 62cdf23a32
commit ce3cfc2c60
Signed by: okennedy
GPG Key ID: 3E5F9B3ABD3FDB60
12 changed files with 1574 additions and 151 deletions

1001
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,28 @@
[package]
name = "pop_pass"
name = "pop_launch_utils"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4.31", features = ["alloc", "serde"] }
date_time_parser = "0.2.0"
num = "0.4.1"
num-derive = "0.4.1"
num-traits = "0.2.17"
radix_trie = "0.2.1"
reqwest = { version = "0.11.23", features = ["blocking"] }
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
serde_with = "3.4.0"
time = "0.3.31"
uuid = { version = "1.6.1", features = ["v4"] }
[[bin]]
name = "pass"
path = "src/pass.rs"
[[bin]]
name = "todo"
path = "src/todo.rs"

19
install.sh Executable file
View File

@ -0,0 +1,19 @@
PLUGINS="todo pass"
cargo build
for plug in $PLUGINS; do
DIR=~/.local/share/pop-launcher/plugins/$plug
BIN=target/debug/$plug
RON=./$plug.ron
if [ ! -d $DIR ] ; then
echo Creating $DIR
mkdir -p $DIR
fi
echo Installing $RON "->" $DIR/plugin.ron
cp $RON $DIR/plugin.ron
echo Installing $BIN "->" $DIR
cp $BIN $DIR
done

View File

@ -1,7 +1,7 @@
(
name: "Pop Pass",
name: "Pass Password Manager",
description: "Syntax: pass [account]",
bin: (path: "pop_pass"),
bin: (path: "pass"),
icon: Name("dialog-password"),
query: (
regex: "^pass\\s.*",

26
src/error.rs Normal file
View File

@ -0,0 +1,26 @@
use std::io;
use std::result;
use std::error;
#[derive(Debug)]
pub enum Error
{
StringErr(String),
IOErr(io::Error),
}
impl From<io::Error> for Error
{
fn from(err: io::Error) -> Error {
Error::IOErr(err)
}
}
impl From<String> for Error
{
fn from(err: String) -> Error {
Error::StringErr(err)
}
}
pub type Result<T> = result::Result<T, Error>;

View File

@ -1,75 +0,0 @@
mod launcher;
mod pass;
mod menu;
use std::io;
use menu::Menu;
use launcher::{ Request, PluginResponse };
use std::result::Result;
fn respond(response: &PluginResponse) -> io::Result<()>
{
let encoded = serde_json::to_string(&response)?;
print!("{}\n", encoded);
Ok(())
}
fn process_request(request_str: &String, menu: &Menu) -> Result<bool, String>
{
let request: Request = serde_json::from_str(&request_str).map_err(|err| err.to_string())?;
match request {
Request::Activate(id) => {
menu.activate(id)?;
respond(&PluginResponse::Close).map_err(|err| err.to_string())?
}
Request::ActivateContext { id:_id, context:_context } =>
(),
Request::Complete(_id) =>
(),
Request::Context(id) =>
respond(
&PluginResponse::Context { id: id, options: Vec::new() }
).map_err(|err| err.to_string())?,
Request::Exit => {
return Ok(false);
}
Request::Interrupt =>
(),
Request::Quit(_id) =>
(),
Request::Search(term) => {
for entry in menu.search(term.trim_start_matches("pass "))
{
respond(&PluginResponse::Append(entry)).map_err(|err| err.to_string())?
}
respond(&PluginResponse::Finished).map_err(|err| err.to_string())?
}
}
Ok(true)
}
fn main() -> Result<(), String> {
let mut keep_going = true;
let mut buffer = String::new();
let stdin = io::stdin();
let passwords = pass::ls().map_err(|err| err.to_string())?;
let menu = Menu::build(passwords);
while keep_going {
if stdin.read_line(&mut buffer).map_err(|err| err.to_string())? > 0
{
keep_going = process_request(&buffer, &menu).map_err(|err| err.to_string())?;
buffer.clear();
} else {
keep_going = false;
}
}
Ok(())
}

View File

@ -1,77 +1,80 @@
use std::vec::Vec;
use std::option::Option;
use std::fs;
mod launcher;
mod pass_lib;
mod pass_menu;
use std::io;
use std::path::Path;
use std::env;
use pass_menu::Menu;
use launcher::{ Request, PluginResponse };
use std::result::Result;
pub type PassList = Vec<PassEntry>;
pub struct PassEntry
fn respond(response: &PluginResponse) -> io::Result<()>
{
pub name: String,
pub children: Option<PassList>
let encoded = serde_json::to_string(&response)?;
print!("{}\n", encoded);
Ok(())
}
fn list_dir(dir: &Path) -> io::Result<PassList>
fn process_request(request_str: &String, menu: &Menu) -> Result<bool, String>
{
let mut list:PassList = Vec::new();
let request: Request = serde_json::from_str(&request_str).map_err(|err| err.to_string())?;
for entry in fs::read_dir(dir)?
{
let entry = entry?.path();
if entry.is_dir()
{
match entry.file_stem()
.and_then( |x| x.to_str() )
{
None => (),
Some(filename) =>
list.push(
PassEntry {
name: String::from(filename),
children: Some(list_dir(&entry.as_path())?)
}
)
}
} else if entry.extension()
.and_then( |x| x.to_str() )
.unwrap_or(&"nogo") == "gpg"
{
match entry.file_stem()
.and_then( |x| x.to_str() )
{
None => (),
Some(filename) =>
list.push(
PassEntry {
name: String::from(filename),
children: None
}
)
}
match request {
Request::Activate(id) => {
respond(&PluginResponse::Close).map_err(|err| err.to_string())?;
menu.activate(id)?;
}
Request::ActivateContext { id, context } =>
menu.activate_context(id, context)?,
Request::Complete(_id) =>
(),
Request::Context(id) =>
respond(
&PluginResponse::Context {
id: id,
options: menu.context(id)
}
).map_err(|err| err.to_string())?,
Request::Exit => {
return Ok(false);
}
Request::Interrupt =>
(),
Request::Quit(_id) =>
(),
Request::Search(term) => {
for entry in menu.search(term.trim_start_matches("pass "))
{
respond(&PluginResponse::Append(entry)).map_err(|err| err.to_string())?
}
respond(&PluginResponse::Finished).map_err(|err| err.to_string())?
}
}
return Ok(list)
Ok(true)
}
pub fn ls() -> io::Result<PassList>
{
// home_dir has a deprecation warning because it is broken on
// windows... but PopOS is linux only.
#[allow(deprecated)]
let mut dir =
match env::home_dir() {
Some(dir) => dir,
None => return Err(io::Error::new(io::ErrorKind::NotFound, "Can't find Password Directory (no home directory)"))
};
dir.push(".password-store");
fn main() -> Result<(), String> {
let mut keep_going = true;
Ok(list_dir(&dir.as_path())?)
}
let mut buffer = String::new();
let stdin = io::stdin();
let passwords = pass_lib::ls().map_err(|err| err.to_string())?;
let menu = Menu::build(passwords);
while keep_going {
if stdin.read_line(&mut buffer).map_err(|err| err.to_string())? > 0
{
keep_going = process_request(&buffer, &menu).map_err(|err| err.to_string())?;
buffer.clear();
} else {
keep_going = false;
}
}
Ok(())
}

77
src/pass_lib.rs Normal file
View File

@ -0,0 +1,77 @@
use std::vec::Vec;
use std::option::Option;
use std::fs;
use std::io;
use std::path::Path;
use std::env;
pub type PassList = Vec<PassEntry>;
pub struct PassEntry
{
pub name: String,
pub children: Option<PassList>
}
fn list_dir(dir: &Path) -> io::Result<PassList>
{
let mut list:PassList = Vec::new();
for entry in fs::read_dir(dir)?
{
let entry = entry?.path();
if entry.is_dir()
{
match entry.file_name()
.and_then( |x| x.to_str() )
{
None => (),
Some(filename) =>
list.push(
PassEntry {
name: String::from(filename),
children: Some(list_dir(&entry.as_path())?)
}
)
}
} else if entry.extension()
.and_then( |x| x.to_str() )
.unwrap_or(&"nogo") == "gpg"
{
match entry.file_stem()
.and_then( |x| x.to_str() )
{
None => (),
Some(filename) =>
list.push(
PassEntry {
name: String::from(filename),
children: None
}
)
}
}
}
return Ok(list)
}
pub fn ls() -> io::Result<PassList>
{
// home_dir has a deprecation warning because it is broken on
// windows... but PopOS is linux only.
#[allow(deprecated)]
let mut dir =
match env::home_dir() {
Some(dir) => dir,
None => return Err(io::Error::new(io::ErrorKind::NotFound, "Can't find Password Directory (no home directory)"))
};
dir.push(".password-store");
Ok(list_dir(&dir.as_path())?)
}

View File

@ -1,7 +1,8 @@
use std::vec::Vec;
use crate::pass::PassList;
use crate::pass_lib::PassList;
use crate::launcher::{self, PluginSearchResult};
use std::process::Command;
use num_derive::FromPrimitive;
pub struct MenuItem
{
@ -14,6 +15,13 @@ pub struct Menu
entries: Vec<MenuItem>
}
#[derive(FromPrimitive)]
enum ContextAction
{
Copy = 1,
Edit,
}
impl Menu
{
fn add_all(&mut self, entries: PassList, prefix: &str, id: &mut u16)
@ -92,6 +100,7 @@ impl Menu
{
let idx = usize::try_from(id)
.map_err(|_err| "Invalid index")?;
if idx >= self.entries.len() { return Err(String::from("Invalid index")) }
let entry = &self.entries[idx];
Command::new("pass")
@ -102,4 +111,48 @@ impl Menu
Ok(())
}
pub fn edit(&self, id: u32) -> Result<(),String>
{
let idx = usize::try_from(id)
.map_err(|_err| "Invalid index")?;
if idx >= self.entries.len() { return Err(String::from("Invalid index")) }
let entry = &self.entries[idx];
Command::new("pass")
.arg("edit")
.arg(entry.full_name.as_str())
.spawn()
.map_err(|err| err.to_string())?;
Ok(())
}
pub fn context(&self, _id: u32) -> Vec<launcher::ContextOption>
{
let mut ret:Vec<launcher::ContextOption> = Vec::new();
ret.push(launcher::ContextOption{
id: ContextAction::Copy as u32,
name: String::from("Copy")
});
ret.push(launcher::ContextOption{
id: ContextAction::Edit as u32,
name: String::from("Edit")
});
return ret
}
pub fn activate_context(&self, id: u32, action: u32) -> Result<(), String>
{
match num::FromPrimitive::from_u32(action)
{
Some(ContextAction::Copy) => self.activate(id)?,
Some(ContextAction::Edit) => self.edit(id)?,
None => return Err(String::from("Invalid context action"))
}
Ok(())
}
}

225
src/todo.rs Normal file
View File

@ -0,0 +1,225 @@
mod launcher;
#[cfg(test)] mod todo_tests;
use chrono::Local;
use serde::{Deserialize, Serialize};
use chrono::NaiveDate;
use launcher::{ Request, PluginResponse, PluginSearchResult, IconSource };
use std::io;
use std::error;
use date_time_parser::DateParser;
use reqwest::blocking::Client;
use uuid::Uuid;
use std::result;
use std::env;
use std::fs;
type Result<T> = result::Result<T, Box<dyn error::Error>>;
struct Todo
{
summary: String,
due: Option<NaiveDate>,
}
#[derive(Serialize, Deserialize, Clone)]
struct Config
{
url: String,
username: String,
password: String,
calendars: Vec<String>
}
struct Session
{
todo: Todo,
config: Config,
}
fn respond(response: &PluginResponse) -> Result<()>
{
let encoded = serde_json::to_string(&response)?;
print!("{}\n", encoded);
Ok(())
}
fn task_string(session: &Session) -> String
{
format!("{}{}",
session.todo.summary,
session.todo.due.map(|d| " ".to_owned() + &d.to_string()).unwrap_or("".to_string()),
)
}
fn build_menu(session: &Session) -> Result<()>
{
let task = task_string(session);
for (idx, cal) in session.config.calendars.iter().enumerate()
{
respond(&PluginResponse::Append(
PluginSearchResult {
id: idx as u32,
name: "Create in ".to_owned()+cal.as_str(),
description: task.clone(),
keywords: None,
icon: Some(IconSource::Name("appointment-new".to_string())),
exec: None,
window: None
}
))?;
}
respond(&PluginResponse::Finished)?;
Ok(())
}
fn parse_task(sentence: &String, session: &mut Session) -> Result<()>
{
let mut summary = String::new();
let mut date:Option<NaiveDate> = None;
for term in sentence.strip_prefix("todo").unwrap_or(sentence).split(" ")
{
if term.is_empty()
{
()
}
else if term.starts_with("@")
{
if let Some(parsed) = term.get(1..).and_then(|d| { DateParser::parse(d.replace("_"," ").as_str()) })
{
date = Some(parsed)
} else {
if !summary.is_empty() { summary.push(' '); }
summary.push_str(term)
}
}
else
{
if !summary.is_empty() { summary.push(' '); }
summary.push_str(term)
}
}
session.todo.summary = summary;
session.todo.due = date;
Ok(())
}
fn gen_caldav(todo: &Todo, uuid: Uuid) -> String
{
let due = match &todo.due
{
None => "".to_string(),
Some(due) => format!("DUE:{}\n", due.format("%Y%m%dT000000").to_string())
};
let mut caldav = String::new();
caldav.push_str("BEGIN:VCALENDAR\n");
caldav.push_str("PRODID:-//okennedy//pop_todo//EN\n");
caldav.push_str("VERSION:2.0\n");
caldav.push_str("BEGIN:VTODO\n");
caldav.push_str(format!("UID:{}\n", uuid).as_str());
caldav.push_str(format!("DTSTAMP:{}\n", Local::now().format("%Y%m%dT%H%M%S").to_string()).as_str());
caldav.push_str(due.as_str());
caldav.push_str(format!("SUMMARY:{}\n", todo.summary).as_str());
caldav.push_str("END:VTODO\n");
caldav.push_str("END:VCALENDAR\n");
return caldav
}
fn publish_todo(todo: &Todo, base_url: &String, username: &String, password: &String, calendar: &String) -> Result<()>
{
let client = Client::new();
let uuid = Uuid::new_v4();
let url = format!("{}/calendars/{}/{}/{}.ics", base_url, username, calendar, uuid);
let caldav = gen_caldav(todo, uuid);
println!("{}", url);
let resp =
client.put(url)
.body(caldav)
.basic_auth(username, Some(password))
.header("Content-Type", "text/calendar")
.send()?;
println!("Status: {}\n{}", resp.status(), resp.text()?);
Ok(())
}
fn process_request(request: &Request, session: &mut Session) -> Result<bool>
{
match request {
Request::Activate(id) => {
respond(&PluginResponse::Clear)?;
publish_todo(
&session.todo,
&session.config.url,
&session.config.username,
&session.config.password,
&session.config.calendars[*id as usize]
)?;
respond(&PluginResponse::Close)?;
}
Request::Exit => {
return Ok(false);
}
Request::Search(term) => {
parse_task(term, session)?;
build_menu(session)?;
}
Request::ActivateContext { id:_id, context:_context } => (),
Request::Complete(_) => (),
Request::Context(_) => (),
Request::Interrupt => (),
Request::Quit(_) => (),
}
Ok(true)
}
fn main() -> Result<()> {
// home_dir has a deprecation warning because it is broken on
// windows... but PopOS is linux only.
#[allow(deprecated)]
let mut config = env::home_dir().unwrap();
config.push(".config");
config.push("pop_todo.json");
let config_string = fs::read_to_string(config)?;
let config = serde_json::from_str(config_string.as_str())?;
let mut session = Session {
todo: Todo {
summary: "No Description".to_string(),
due: None,
},
config,
};
let mut keep_going = true;
let mut buffer = String::new();
let stdin = io::stdin();
while keep_going {
if stdin.read_line(&mut buffer)? > 0
{
let request: Request = serde_json::from_str(&buffer)?;
keep_going = process_request(&request, &mut session)?;
buffer.clear();
} else {
keep_going = false;
}
}
Ok(())
}

87
src/todo_tests.rs Normal file
View File

@ -0,0 +1,87 @@
use date_time_parser::DateParser;
use chrono::{ Local, Datelike, Duration };
use chrono::naive::NaiveDate;
use uuid::Uuid;
use crate::{Session, Todo, parse_task, gen_caldav};
#[test]
fn test_date_creation()
{
let date1 = DateParser::parse("today");
let date1cmp = Local::now();
assert_eq!(NaiveDate::from_ymd_opt(date1cmp.year(), date1cmp.month(), date1cmp.day()), date1);
let date2 = DateParser::parse("tomorrow");
let date2cmp = Local::now() + Duration::days(1);
assert_eq!(NaiveDate::from_ymd_opt(date2cmp.year(), date2cmp.month(), date2cmp.day()), date2);
}
#[test]
fn test_parser()
{
let mut session = Session {
todo: Todo {
summary: "".to_string(),
due: None
},
config: crate::Config {
url: "https://foo".to_string(),
username: "".to_string(),
password: "".to_string(),
calendars: Vec::new()
}
};
let ret = parse_task(&"do the thing @tomorrow".to_string(), &mut session);
assert!(ret.is_ok());
assert_eq!("do the thing", session.todo.summary);
let tomorrow = DateParser::parse("tomorrow");
assert_eq!(tomorrow, session.todo.due);
}
#[test]
fn test_incremental_parser()
{
let mut session = Session {
todo: Todo {
summary: "".to_string(),
due: None
},
config: crate::Config {
url: "https://foo".to_string(),
username: "".to_string(),
password: "".to_string(),
calendars: Vec::new()
}
};
let mut task = "".to_string();
for chr in "do the thing @tomorrow".chars()
{
task.push(chr);
let ret =
parse_task(&task, &mut session);
assert!(ret.is_ok());
}
assert_eq!("do the thing", session.todo.summary);
let tomorrow = DateParser::parse("tomorrow");
assert_eq!(tomorrow, session.todo.due);
}
#[test]
fn test_gen_caldav()
{
let todo = Todo { summary: "Hello World".to_string(), due: DateParser::parse("tomorrow") };
let uuid = Uuid::new_v4();
let test = gen_caldav(&todo, uuid);
assert!(test.contains("SUMMARY:Hello World"));
}

11
todo.ron Normal file
View File

@ -0,0 +1,11 @@
(
name: "Todo",
description: "Syntax: todo [task]",
bin: (path: "todo"),
icon: Name("task-due"),
query: (
regex: "^todo\\s.*",
isolate: true,
help: "todo",
)
)